Vela UI Docsvenizia.ai

Doc Typewriter

Text that types itself in with a caret, keeps typing when the text grows, and shows at once for reduced motion - how an assistant's writing arrives.

npx shadcn@latest add @velaui/doc-typewriter
npx shadcn@latest add @velaui/doc-typewriter

Examples

  • Assistant writing
  • Appended

View code

Doc Typewriter · Assistant writing

Installation

pnpm dlx velaui add @velaui/doc-typewriter
pnpm dlx velaui add @velaui/doc-typewriter

Code

doc-typewriter-assistant-writing.tsx
import * as React from "react";

import { DocTypewriter } from "@/components/ui/doc-typewriter";

const REPLY =
  "Here is the plan: freeze the schema on Thursday, run the dry migration on Friday morning, and cut over once finance signs off.";

export default function Example() {
  const [run, setRun] = React.useState(0);
  return (
    <div className="w-full max-w-xl space-y-3 p-6">
      <DocTypewriter key={run} value={REPLY} />
      <button type="button" className="text-xs text-text-muted underline" onClick={() => setRun(run + 1)}>
        Write again
      </button>
    </div>
  );
}

View code

Doc Typewriter · Appended

Installation

pnpm dlx velaui add @velaui/doc-typewriter
pnpm dlx velaui add @velaui/doc-typewriter

Code

doc-typewriter-appended.tsx
import * as React from "react";

import { DocTypewriter } from "@/components/ui/doc-typewriter";

const PARTS = ["Step one: freeze the schema.", " Step two: run the dry migration.", " Step three: migrate."];

export default function Example() {
  const [count, setCount] = React.useState(1);
  return (
    <div className="w-full max-w-xl space-y-3 p-6">
      <DocTypewriter value={PARTS.slice(0, count).join("")} onDone={() => count < PARTS.length && setCount(count + 1)} />
    </div>
  );
}

Usage

import * as React from "react";

import { DocTypewriter } from "@/components/ui/doc-typewriter";

const REPLY =
  "Here is the plan: freeze the schema on Thursday, run the dry migration on Friday morning, and cut over once finance signs off.";

export default function Example() {
  const [run, setRun] = React.useState(0);
  return (
    <div className="w-full max-w-xl space-y-3 p-6">
      <DocTypewriter key={run} value={REPLY} />
      <button type="button" className="text-xs text-text-muted underline" onClick={() => setRun(run + 1)}>
        Write again
      </button>
    </div>
  );
}

Props

PropTypeDefault
onDone() => void—
speednumber28
valuerequiredstring—

Takes these and every prop of DocTypewriterProps.

Dependencies

npm
none besides React
Installs with it
nothing else

Source

components/ui/doc-typewriter.tsx
import * as React from "react";

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

export interface DocTypewriterProps extends Omit<React.ComponentProps<"p">, "children"> {
  /** The full text. When it grows, the new tail is typed on from where it stood. */
  value: string;
  /** Milliseconds per character. */
  speed?: number;
  onDone?: () => void;
}

/** Text that types itself in, with a caret while it runs; shown at once for reduced motion. */
function DocTypewriter({ value, speed = 28, onDone, className, ...props }: DocTypewriterProps) {
  const [shown, setShown] = React.useState(0);
  const done = React.useRef(onDone);
  done.current = onDone;
  const still = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
  const length = still ? value.length : Math.min(shown, value.length);

  React.useEffect(() => {
    if (still || shown >= value.length) {
      if (value.length > 0 && shown >= value.length) done.current?.();
      return;
    }
    const t = setTimeout(() => setShown(shown + 1), speed);
    return () => clearTimeout(t);
  }, [shown, value, speed, still]);

  // A rewritten value starts over; an appended one keeps typing.
  const last = React.useRef(value);
  React.useEffect(() => {
    if (!value.startsWith(last.current)) setShown(0);
    last.current = value;
  }, [value]);

  return (
    <p className={cn("text-sm leading-[1.6] text-text-strong", className)} {...props}>
      <span className="sr-only">{value}</span>
      <span aria-hidden>{value.slice(0, length)}</span>
      {length < value.length && <span aria-hidden className="ml-px inline-block h-[1.1em] w-px translate-y-[0.2em] animate-pulse bg-current" />}
    </p>
  );
}

export { DocTypewriter };