effects
Typewriter Text
A phrase-cycling typewriter that types each character in, holds, then erases at ~2× speed before cycling. Differentiators: a thin 2px caret that fades on a non-linear ease (OLED-cursor feel, not an 80s blink) and an optional static 'anchor' prefix so hero headings stay compositionally stable while only the suffix animates. Accessible (role=text + polite live region), hydration-safe, and reduced-motion aware (hard instant reveal).
We build fast sites, bold brands, real systems
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/typewriter-textUsage
Usagetsx
<TypewriterText phrases={[]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| phrases* | string[] | — | Ordered list of full strings to cycle through. Required, min 1 item. |
| anchor | string | — | Optional static prefix rendered before the animated suffix. When provided, each entry in `phrases` is treated as the suffix only. |
| typingSpeed | number | 55 | Milliseconds per character during type-in. Default 55. |
| deletingSpeed | number | 28 | Milliseconds per character during erase. Default 28 (~2× faster). |
| pauseAfterType | number | 1800 | Ms to hold the completed phrase before erasing. Default 1800. |
| pauseAfterDelete | number | 400 | Ms to hold the empty caret before typing the next phrase. Default 400. |
| loop | boolean | true | Whether to cycle indefinitely. Default true. When false, stops after the last phrase is fully typed (caret remains, blinking). |
| caretColor | string | — | CSS color overriding the caret. Default: var(--primary). |
| className | string | Additional Tailwind classes applied to the root <span>. | |
| aria-label | string | — | Full accessible label. When omitted, a comma-joined list of all phrases (prefixed by `anchor` where set) is computed as the accessible name. |
Source
typewriter-text.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import styles from "./typewriter-text.module.css";
export interface TypewriterTextProps {
/** Ordered list of full strings to cycle through. Required, min 1 item. */
phrases: string[];
/**
* Optional static prefix rendered before the animated suffix. When provided,
* each entry in `phrases` is treated as the suffix only.
*/
anchor?: string;
/** Milliseconds per character during type-in. Default 55. */
typingSpeed?: number;
/** Milliseconds per character during erase. Default 28 (~2× faster). */
deletingSpeed?: number;
/** Ms to hold the completed phrase before erasing. Default 1800. */
pauseAfterType?: number;
/** Ms to hold the empty caret before typing the next phrase. Default 400. */
pauseAfterDelete?: number;
/**
* Whether to cycle indefinitely. Default true. When false, stops after the
* last phrase is fully typed (caret remains, blinking).
*/
loop?: boolean;
/** CSS color overriding the caret. Default: var(--primary). */
caretColor?: string;
/** Additional Tailwind classes applied to the root <span>. */
className?: string;
/**
* Full accessible label. When omitted, a comma-joined list of all phrases
* (prefixed by `anchor` where set) is computed as the accessible name.
*/
"aria-label"?: string;
}
type Phase = "typing" | "paused-full" | "deleting" | "paused-empty";
export function TypewriterText({
phrases,
anchor,
typingSpeed = 55,
deletingSpeed = 28,
pauseAfterType = 1800,
pauseAfterDelete = 400,
loop = true,
caretColor,
className = "",
"aria-label": ariaLabel,
}: TypewriterTextProps) {
// Mount-gate so SSR HTML === client first render (avoids React #418).
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
const animate = mounted && !reduce;
// Accessible name: user-supplied, else comma-joined full phrases.
const fullPhrases = phrases.map((p) => (anchor ? `${anchor}${p}` : p));
const computedLabel = ariaLabel ?? fullPhrases.join(", ");
// Static fallback text used for SSR / first paint / reduced-motion. Renders
// the whole set as a comma list when looping over several phrases, else the
// first phrase in full — a hard instant reveal, never a slowed animation.
const staticSuffix =
loop && phrases.length > 1 ? phrases.join(", ") : (phrases[0] ?? "");
// Animation state. Initialise to the first character of the first phrase so
// there is no pre-mount empty flash when animation kicks in post-hydration.
const [phraseIndex, setPhraseIndex] = useState(0);
const [text, setText] = useState(() => (phrases[0] ? phrases[0].slice(0, 1) : ""));
const [phase, setPhase] = useState<Phase>("typing");
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!animate) return;
const current = phrases[phraseIndex] ?? "";
const clear = () => {
if (timer.current) clearTimeout(timer.current);
};
if (phase === "typing") {
if (text.length < current.length) {
// ±15ms organic jitter on type-in only (Math.random is post-mount,
// inside an effect — never runs during SSR/first paint).
const jitter = Math.random() * 30 - 15;
const delay = Math.max(0, typingSpeed + jitter);
timer.current = setTimeout(() => {
setText(current.slice(0, text.length + 1));
}, delay);
} else {
const isLast = phraseIndex === phrases.length - 1;
if (!loop && isLast) return; // complete: caret blinks, no transition
timer.current = setTimeout(() => setPhase("paused-full"), pauseAfterType);
}
return clear;
}
if (phase === "paused-full") {
timer.current = setTimeout(() => setPhase("deleting"), 0);
return clear;
}
if (phase === "deleting") {
if (text.length > 0) {
// Erase is mechanically uniform — no jitter, a confident backspace.
timer.current = setTimeout(() => {
setText(current.slice(0, text.length - 1));
}, deletingSpeed);
} else {
timer.current = setTimeout(() => setPhase("paused-empty"), pauseAfterDelete);
}
return clear;
}
// paused-empty: advance to next phrase and resume typing.
setPhraseIndex((i) => (i + 1) % phrases.length);
setPhase("typing");
return clear;
}, [
animate,
phase,
text,
phraseIndex,
phrases,
loop,
typingSpeed,
deletingSpeed,
pauseAfterType,
pauseAfterDelete,
]);
// Caret is solid (frozen) while characters are moving; blinks while paused.
// The blink class is only added in the animated path — the static/reduced/
// first-paint base renders the motionless `styles.caret` alone.
const caretSolid = phase === "typing" || phase === "deleting";
const caretClass = [
styles.caret,
styles.caretBlink,
caretSolid ? styles.caretSolid : "",
]
.filter(Boolean)
.join(" ");
const suffix = animate ? text : staticSuffix;
// Live-region surfaces the complete phrase only (updates fire on completion,
// not per character) — for animated mode that's the full current phrase.
const liveText = animate ? (phrases[phraseIndex] ?? "") : staticSuffix;
return (
<span
role="text"
aria-label={computedLabel}
className={["inline-flex items-baseline text-foreground", className]
.filter(Boolean)
.join(" ")}
>
{/* Visually-hidden live region: announces completed phrases politely. */}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{anchor ? `${anchor}${liveText}` : liveText}
</span>
{/* Visual layer is hidden from AT — the label + live region carry meaning. */}
<span aria-hidden="true">
{anchor ? <span className="font-[inherit]">{anchor}</span> : null}
<span>{suffix}</span>
<span
className={animate ? caretClass : styles.caret}
style={
caretColor
? ({ "--caret-color": caretColor } as React.CSSProperties)
: undefined
}
/>
</span>
</span>
);
}
Dependencies
- motion
- @bottega/tokens