Typewriter
Types, holds and deletes with a caret, over one or several lines. The space is reserved, so nothing around it moves.
One of 5 components in Text effects
Details
What Typewriter brings along, read from the registry and the source.
- Category
- Text effects
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/typewriter.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The TypewriterProps interface from components/ui/typewriter.tsx, with the comments from the code.
linesrequiredstring[]One or more lines. Each is typed, held, deleted, then the next one follows.
asoptionalTagh1h2h3h4pspandiv
Element to render. Default span, so it sits inside a heading.
loopoptionalbooleanStart over after the last line. Default true with more than one line.
typeSpeedoptionalnumberMilliseconds per typed character, varied a little like a hand. Default 60.
deleteSpeedoptionalnumberMilliseconds per deleted character. Default 28.
holdoptionalnumberMilliseconds a finished line stays. Default 1900.
startDelayoptionalnumberMilliseconds before the first key. Default 350.
caretoptionalTypewriterCaretbarblockunderscorenone
Caret shape. Default bar, in the accent color (set --kitfx-tw-caret to change it).
triggeroptional'load' | 'view' | 'in-view' | 'scroll'load: types right away. view (or in-view): once it scrolls in (default). scroll: the first line is typed by the scroll position, it types on the way into the window and deletes again when you scroll back.
scrollRangeoptional[number, number]With trigger scroll: the part of the way through the window that types the line, 0 to 1. Default [0.05, 0.55].
labeloptionalstringWhat screen readers hear. Default: the lines joined with commas (one line: that line).
Accessibility
Read from the source of the component.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 228 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';
export type TypewriterCaret = 'bar' | 'block' | 'underscore' | 'none';
type Tag = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span' | 'div';
export interface TypewriterProps {
/** One or more lines. Each is typed, held, deleted, then the next one follows. */
lines: string[];
/** Element to render. Default span, so it sits inside a heading. */
as?: Tag;
/** Start over after the last line. Default true with more than one line. */
loop?: boolean;
/** Milliseconds per typed character, varied a little like a hand. Default 60. */
typeSpeed?: number;
/** Milliseconds per deleted character. Default 28. */
deleteSpeed?: number;
/** Milliseconds a finished line stays. Default 1900. */
hold?: number;
/** Milliseconds before the first key. Default 350. */
startDelay?: number;
/** Caret shape. Default bar, in the accent color (set --kitfx-tw-caret to change it). */
caret?: TypewriterCaret;
/**
* load: types right away. view (or in-view): once it scrolls in (default).
* scroll: the first line is typed by the scroll position, it types on the
* way into the window and deletes again when you scroll back.
*/
trigger?: 'load' | 'view' | 'in-view' | 'scroll';
/** With trigger scroll: the part of the way through the window that types the line, 0 to 1. Default [0.05, 0.55]. */
scrollRange?: [number, number];
/** What screen readers hear. Default: the lines joined with commas (one line: that line). */
label?: string;
className?: string;
}
/*
* Space is reserved by all lines stacked invisibly in one grid cell, so the
* surrounding text never moves. The visible line always contains its full
* text, the part not typed yet is only invisible: a word therefore never
* jumps to the next line while it is being typed.
* Before hydration the visible line is hidden (only with scripting on and
* motion allowed, and at most 2.5 s), so the full line does not flash and
* then disappear; without JavaScript the line is simply there.
*/
const CSS = `
.kitfx-tw{display:inline-grid;max-width:100%}
.kitfx-tw>*{grid-area:1/1}
.kitfx-tw-ghost{visibility:hidden}
.kitfx-tw-rest{visibility:hidden}
.kitfx-tw-caret{position:relative}
.kitfx-tw-caret::after{content:"";position:absolute;left:.04em;top:.11em;height:.98em;width:.07em;border-radius:1px;background:var(--kitfx-tw-caret,color-mix(in oklch,var(--primary) 75%,var(--foreground)));animation:kitfx-tw-blink 1.06s steps(1) infinite}
.kitfx-tw[data-caret=block] .kitfx-tw-caret::after{width:.52em;left:.03em;opacity:.85}
.kitfx-tw[data-caret=underscore] .kitfx-tw-caret::after{top:.99em;height:.08em;width:.52em}
.kitfx-tw[data-typing] .kitfx-tw-caret::after{animation:none}
.kitfx-tw-caret[data-off]::after{opacity:0;transition:opacity .4s}
@keyframes kitfx-tw-blink{50%{opacity:0}}
@keyframes kitfx-tw-hold{from{visibility:hidden}}
@media (scripting:enabled) and (prefers-reduced-motion:no-preference){.kitfx-tw[data-pending] .kitfx-tw-line{animation:kitfx-tw-hold 0s 2.5s both}}
@media (prefers-reduced-motion:reduce){.kitfx-tw-caret::after{display:none}}`;
type Phase = 'typing' | 'holding' | 'deleting' | 'done';
/**
* Scroll link. Writes --kitfx-p (0 to 1) on the element: 0 when it has
* travelled `start` of its way through the window, 1 at `end` (0 is its top
* at the bottom edge, 1 its bottom at the top edge). The value is scaled to
* the part of that way the page can still scroll, so a line at the end of a
* short page still finishes. Never with reduced motion, then the CSS
* fallback 1 keeps the final state.
*/
function useScrollLink(ref: { current: HTMLElement | null }, on: boolean, range: readonly [number, number], onChange?: (p: number) => void) {
const [start, end] = range;
const listener = useRef(onChange);
listener.current = onChange;
useEffect(() => {
const el = ref.current;
if (!el || !on || prefersReducedMotion()) return;
let frame = 0;
const span = Math.max(0.0001, end - start);
const at = (top: number, h: number, vh: number) => Math.min(1, Math.max(0, ((vh - top) / (h + vh) - start) / span));
const update = () => {
frame = 0;
const r = el.getBoundingClientRect();
const vh = window.innerHeight;
const rest = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY);
const most = at(r.top - rest, r.height, vh);
const p = most < 0.02 ? 1 : Math.min(1, at(r.top, r.height, vh) / most);
el.style.setProperty('--kitfx-p', p.toFixed(4));
listener.current?.(p);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
el.style.removeProperty('--kitfx-p');
};
}, [ref, on, start, end]);
}
/** A stable pseudo random number in [0, 1) for a position, so typing rhythm is the same on every visit. */
const jitter = (n: number) => {
const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
};
/**
* Types a line letter by letter, deletes it and types the next. The caret
* blinks while it waits and stays solid while it types. Pauses off screen.
* Screen readers get the label once, never the letters. Without JavaScript
* and with reduced motion the (last, or with loop the first) line stands
* complete without a caret.
* <h1>Websites for <Typewriter lines={['law firms', 'cafés', 'studios']} /></h1>
*/
export function Typewriter({ lines, as: Tag = 'span', loop, typeSpeed = 60, deleteSpeed = 28, hold = 1900, startDelay = 350, caret = 'bar', trigger = 'view', scrollRange = [0.05, 0.55], label, className }: TypewriterProps) {
const many = lines.length > 1;
const repeat = loop ?? many;
const finalLine = repeat || trigger === 'scroll' ? 0 : lines.length - 1;
const ref = useRef<HTMLElement>(null);
const [line, setLine] = useState(finalLine);
const [count, setCount] = useState(lines[finalLine]?.length ?? 0);
const [phase, setPhase] = useState<Phase>('done');
const [pending, setPending] = useState(true);
const [active, setActive] = useState(false);
const [onScreen, setOnScreen] = useState(true);
const scroll = trigger === 'scroll';
// Scroll: the position types the first line, the caret stays until it is complete
useScrollLink(ref, scroll && lines.length > 0, scrollRange, (p) => {
const full = lines[0]?.length ?? 0;
setLine(0);
setCount(p >= 1 ? full : Math.round(p * full));
setPhase(p >= 1 ? 'done' : 'typing');
});
// Start: reset to an empty first line and wait for the screen
useEffect(() => {
setPending(false);
if (prefersReducedMotion() || !lines.length || scroll) return;
setLine(0);
setCount(0);
setPhase('typing');
const el = ref.current;
if (!el) return;
let started = trigger === 'load';
if (started) setActive(true);
const io = new IntersectionObserver(
([e]) => {
const on = !!e?.isIntersecting;
setOnScreen(on);
if (on && !started) {
started = true;
setActive(true);
}
},
{ rootMargin: '0px 0px -10% 0px' },
);
io.observe(el);
return () => io.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lines.join('\n'), trigger, scroll]);
// The clock: one step at a time, only while active and on screen
useEffect(() => {
if (!active || !onScreen || phase === 'done') return;
const text = lines[line] ?? '';
let wait = 0;
let step: () => void;
if (phase === 'typing') {
if (count >= text.length) {
const last = line === lines.length - 1;
wait = 0;
step = () => setPhase(last && !repeat ? 'done' : 'holding');
} else {
const ch = text[count] ?? '';
const pause = /[,.;!?]/.test(text[count - 1] ?? '') ? 3.5 : ch === ' ' ? 1.6 : 1;
wait = (count === 0 && line === 0 ? startDelay : 0) + typeSpeed * pause * (0.65 + jitter(line * 97 + count) * 0.7);
step = () => setCount((c) => c + 1);
}
} else if (phase === 'holding') {
wait = hold;
step = () => setPhase('deleting');
} else {
if (count <= 0) {
wait = 320;
step = () => {
setLine((i) => (i + 1) % lines.length);
setPhase('typing');
};
} else {
wait = deleteSpeed;
step = () => setCount((c) => c - 1);
}
}
const timer = window.setTimeout(step, wait);
return () => window.clearTimeout(timer);
}, [active, onScreen, phase, count, line, lines, repeat, typeSpeed, deleteSpeed, hold, startDelay]);
const text = lines[line] ?? '';
const typing = (active || scroll) && (phase === 'typing' || phase === 'deleting');
const showCaret = caret !== 'none' && phase !== 'done' && !pending;
const name = label ?? (many && !scroll ? lines.join(', ') : (lines[0] ?? ''));
return (
<Tag ref={ref as never} className={cn('kitfx-tw', className)} data-caret={caret} data-typing={typing ? '' : undefined} data-pending={pending ? '' : undefined}>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<span className="sr-only">{name}</span>
{lines.map((l, i) => (
<span key={i} aria-hidden className="kitfx-tw-ghost">
{l}
</span>
))}
<span aria-hidden className="kitfx-tw-line">
{text.slice(0, count)}
{showCaret && <span className="kitfx-tw-caret" />}
<span className="kitfx-tw-rest">{text.slice(count)}</span>
</span>
</Tag>
);
}Frequent questions
How do I install Typewriter?
With aiw add @ai-website/typewriter. The command puts components/ui/typewriter.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Typewriter have?
3 variants, Hero loop, Prompt on scroll and Command.
Can an AI assistant build with Typewriter?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Typewriter with aiw_components and install it with aiw_add @ai-website/typewriter.