Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions animata/text/roll-text.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@
white-space: pre;
}

/* inline-grid + overflow:hidden keeps the text baseline (from the sizer in
row 1) while clipping the translated stack. */
/* inline-grid + clip keeps the text baseline (from the sizer in row 1)
while clipping the translated stack. Prefer clip-path/contain over plain
overflow:hidden — sticky headers with backdrop-filter promote compositing
layers that can let transformed children paint outside overflow:hidden. */
.roll-unit {
position: relative;
display: inline-grid;
overflow: hidden;
overflow: clip;
clip-path: inset(0);
contain: paint;
vertical-align: baseline;
font-kerning: none;
line-height: inherit;
Expand Down
36 changes: 22 additions & 14 deletions animata/text/ticker.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"use client";

import { motion, useInView, useMotionValue, useSpring } from "motion/react";

import { useCallback, useEffect, useRef } from "react";
import { useCallback, useLayoutEffect, useRef } from "react";

import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -32,31 +31,40 @@ function Number({

const isRaw = String(+value) !== value;

useEffect(() => {
if (!isInView || isRaw || !numberRef.current) {
return;
}
useLayoutEffect(() => {
if (!isInView || isRaw) return;

const update = () => {
const height = getHeight();
springValue.set(-height * +value);
// Add a delay to prevent the spring from firing too early.
};
const height = getHeight();
if (!height) return;

const target = -height * +value;

// Correct digit on first paint — avoid the "0,000+" trust flash.
if (!delay) {
update();
motionValue.jump(target);
return;
}

const timer = setTimeout(update, (total - index) * Math.floor(Math.random() * delay));
motionValue.jump(0);
const timer = setTimeout(
() => {
springValue.set(target);
},
(total - index) * Math.floor(Math.random() * delay),
);

return () => clearTimeout(timer);
}, [value, isRaw, isInView, springValue, getHeight, index, total, delay]);
}, [value, isRaw, isInView, springValue, motionValue, getHeight, index, total, delay]);

if (isRaw) {
return <span>{value}</span>;
}

// Static digit until in view so overflow strip never shows a wall of 0–9.
if (!isInView) {
return <span className={className}>{value}</span>;
}

return (
<motion.div
ref={numberRef}
Expand Down
151 changes: 96 additions & 55 deletions animata/text/typing-text.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import {
type Dispatch,
type MutableRefObject,
type ReactNode,
type SetStateAction,
useEffect,
useMemo,
useRef,
useState,
} from "react";

import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -124,6 +133,65 @@ enum TypingDirection {
Backward = -1,
}

function useTypingInterval(
paused: boolean,
direction: TypingDirection,
total: number,
stepMs: number,
setIndex: Dispatch<SetStateAction<number>>,
) {
useEffect(() => {
if (paused) return;

const interval = setInterval(() => {
setIndex((current) => {
const next = current + direction;
if (direction === TypingDirection.Forward) return Math.min(next, total);
return Math.max(next, 0);
});
}, stepMs);

return () => clearInterval(interval);
}, [direction, stepMs, total, paused, setIndex]);
}

function useTypingEndpoint(
atEnd: boolean,
atStart: boolean,
direction: TypingDirection,
repeat: boolean | undefined,
waitTime: number,
setDirection: Dispatch<SetStateAction<TypingDirection>>,
onCompleteRef: MutableRefObject<(() => void) | undefined>,
completedRef: MutableRefObject<boolean>,
) {
useEffect(() => {
if (!atEnd && !atStart) return;

if (atEnd && direction === TypingDirection.Forward) {
if (!repeat) {
if (!completedRef.current) {
completedRef.current = true;
onCompleteRef.current?.();
}
return;
}

const timeout = setTimeout(() => {
setDirection(TypingDirection.Backward);
}, waitTime);
return () => clearTimeout(timeout);
}

if (atStart && direction === TypingDirection.Backward && repeat) {
const timeout = setTimeout(() => {
setDirection(TypingDirection.Forward);
}, waitTime);
return () => clearTimeout(timeout);
}
}, [atEnd, atStart, direction, repeat, waitTime, setDirection, onCompleteRef, completedRef]);
}

function CursorWrapper({
visible,
children,
Expand Down Expand Up @@ -162,65 +230,38 @@ function Type({
hideCursorOnComplete,
}: TypingTextProps) {
const [index, setIndex] = useState(0);
const directionRef = useRef(TypingDirection.Forward);
const [direction, setDirection] = useState<TypingDirection>(TypingDirection.Forward);
const onCompleteRef = useRef(onComplete);
const completedRef = useRef(false);
onCompleteRef.current = onComplete;

const words = useMemo(() => text.split(/\s+/), [text]);
const total = smooth ? words.length : text.length;
const isComplete = index === total && !repeat;
const stepMs = Math.max(1, delay ?? 32);
const caret = Math.min(Math.max(index, 0), total);
if (index !== caret) {
setIndex(caret);
}
const isComplete = caret === total && !repeat;
const atEnd = caret >= total;
const atStart = caret <= 0;
const paused =
(atEnd && direction === TypingDirection.Forward) ||
(atStart && direction === TypingDirection.Backward);

useEffect(() => {
let interval: ReturnType<typeof setInterval> | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;

const startInterval = () => {
interval = setInterval(() => {
setIndex((current) => {
const direction = directionRef.current;
const next = current + direction;

if (direction === TypingDirection.Forward && next >= total) {
if (!repeat) {
if (!completedRef.current) {
completedRef.current = true;
onCompleteRef.current?.();
}
if (interval) clearInterval(interval);
return total;
}
if (interval) clearInterval(interval);
timeout = setTimeout(() => {
directionRef.current = TypingDirection.Backward;
startInterval();
}, waitTime);
return total;
}

if (direction === TypingDirection.Backward && next <= 0) {
if (interval) clearInterval(interval);
timeout = setTimeout(() => {
directionRef.current = TypingDirection.Forward;
startInterval();
}, waitTime);
return 0;
}

return next;
});
}, delay);
};

startInterval();

return () => {
if (interval) clearInterval(interval);
if (timeout) clearTimeout(timeout);
};
}, [total, delay, repeat, waitTime]);

const waitingNextCycle = index === total || index === 0;
useTypingInterval(paused, direction, total, stepMs, setIndex);
useTypingEndpoint(
atEnd,
atStart,
direction,
repeat,
waitTime,
setDirection,
onCompleteRef,
completedRef,
);

const waitingNextCycle = caret === total || caret === 0;

return (
<div className={cn("relative font-mono", className)}>
Expand All @@ -231,9 +272,9 @@ function Type({
})}
>
{smooth ? (
<SmoothEffect words={words} index={index} alwaysVisibleCount={alwaysVisibleCount ?? 1} />
<SmoothEffect words={words} index={caret} alwaysVisibleCount={alwaysVisibleCount ?? 1} />
) : (
<NormalEffect text={text} index={index} alwaysVisibleCount={alwaysVisibleCount ?? 1} />
<NormalEffect text={text} index={caret} alwaysVisibleCount={alwaysVisibleCount ?? 1} />
)}
<CursorWrapper
waiting={waitingNextCycle}
Expand Down
20 changes: 7 additions & 13 deletions app/(main)/_landing/call-to-action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ import { useEffect, useRef, useState } from "react";

import GibberishText from "@/animata/text/gibberish-text";
import ComponentLinkWrapper from "@/components/component-link-wrapper";
import { Icons } from "@/components/icons";
import { GitHubStarLink } from "@/components/github-star-link";
import RemountOnMouseIn from "@/components/remount-on-mouse-in";
import { siteConfig } from "@/config/site";

export default function CallToActionSection() {
const headerRef = useRef<HTMLHeadingElement>(null);
Expand Down Expand Up @@ -40,22 +39,17 @@ export default function CallToActionSection() {
weeks of work.
</p>

<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row">
<div className="mt-8 flex w-full max-w-md flex-col gap-3 sm:max-w-none sm:flex-row sm:items-center sm:justify-center">
<Link
href="/docs"
className="inline-flex items-center justify-center rounded-full bg-[hsl(var(--accent))] px-8 py-3.5 text-[16px] font-semibold text-white transition-opacity hover:opacity-90"
className="inline-flex w-full items-center justify-center rounded-full bg-[hsl(var(--accent))] px-8 py-3.5 text-[16px] font-semibold text-white transition-opacity hover:opacity-90 sm:w-auto"
>
Get started now
</Link>
<Link
href={siteConfig.links.github}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-[15px] font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<Icons.gitHub className="size-4" />
View on GitHub
</Link>
<GitHubStarLink
source="cta"
className="inline-flex w-full items-center justify-center gap-2 rounded-full border border-border bg-background px-8 py-3.5 text-[15px] font-semibold text-foreground transition-colors hover:border-foreground/25 hover:bg-foreground/3 sm:w-auto"
/>
</div>
</div>
</section>
Expand Down
Loading
Loading