Zum Inhalt springen
UI Kit
Accent

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

3 Variants

Variants

Auftakt in Schleife

Die letzten Wörter einer Überschrift wechseln sich ab, getippt und gelöscht in Schleife.

Eingabe beim Scrollen

Ein langer Satz, getippt von der Scrollposition, natürlich umbrochen, mit Blockcursor. Zurückscrollen löscht ihn.

Befehl

Befehle im Terminal, jeder getippt und vom nächsten ersetzt, mit Unterstrich als Cursor.

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

Props

Die Schnittstelle TypewriterProps aus components/ui/typewriter.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • linesPflicht
    string[]

    One or more lines. Each is typed, held, deleted, then the next one follows.

  • asoptional
    Tag

    h1h2h3h4pspandiv

    Element to render. Default span, so it sits inside a heading.

  • loopoptional
    boolean

    Start over after the last line. Default true with more than one line.

  • typeSpeedoptional
    number

    Milliseconds per typed character, varied a little like a hand. Default 60.

  • deleteSpeedoptional
    number

    Milliseconds per deleted character. Default 28.

  • holdoptional
    number

    Milliseconds a finished line stays. Default 1900.

  • startDelayoptional
    number

    Milliseconds before the first key. Default 350.

  • caretoptional
    TypewriterCaret

    barblockunderscorenone

    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].

  • labeloptional
    string

    What 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
components/ui/typewriter.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert