Schreibmaschine
Tippt, hält und löscht mit Cursor, über eine oder mehrere Zeilen. Der Platz ist reserviert, nichts drumherum bewegt sich.
Eine von 5 Components in Text Effects
Details
Was Schreibmaschine mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Text Effects
- Bereich
- Elements
- Variants
- 3
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/typewriter.tsx
- Bringt mit
- utilskit
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle TypewriterProps aus components/ui/typewriter.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
linesPflichtstring[]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).
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 228 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Schreibmaschine?
Mit aiw add @ai-website/typewriter. Der Befehl legt die Datei components/ui/typewriter.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Schreibmaschine?
3 Varianten, Auftakt in Schleife, Eingabe beim Scrollen und Befehl.
Kann ein KI-Assistent Schreibmaschine einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Schreibmaschine mit aiw_components und installieren sie mit aiw_add @ai-website/typewriter.