Zum Inhalt springen
UI Kit
Accent

Annotationen

Handgezeichnete Markierungen, die auf etwas zeigen. Pfeile in fünf Formen, Klammern, Haken, Kreuze, Durchstreichungen, Betonungsstriche, nummerierte Marken und Notizen zeichnen sich beim Hereinscrollen selbst, jede mit ihrem eigenen leichten Zittern.

Eine von 5 Components in Graphics

5 Variants

Variants

Hero mit Hinweis

Ein Kreis zeichnet sich um das Schlüsselwort, Notiz und geschwungener Pfeil zeigen auf den Hauptknopf. Auf dem Telefon steht die Notiz über dem Knopf, der Pfeil zeigt nach unten.

Im Studio öffnen

Kommentierter Screenshot

Nummerierte Marken auf einem Screenshot mit Notizen daneben. Hover oder Fokus auf einer Notiz vergrößert ihre Marke, die anderen hören auf zu pulsieren. Telefone setzen die Notizen unter das Bild.

Im Studio öffnen

Preise markiert

Der alte Preis wird durchgestrichen, die Leistungen bekommen nacheinander ihre Haken und eine Notiz mit Pfeil nennt den Tarif, den die meisten nehmen.

Im Studio öffnen

Checkliste

Eine Startliste, in der Haken und Striche beim Hereinscrollen nacheinander erscheinen, ein Kreuz für Gestrichenes und eine Klammer, die den ersten Teil zusammenfasst.

Im Studio öffnen

Alle Markierungen

Jede Markierung des Sets auf einem Blatt, mit ihrem Code. Noch mal zeichnen spielt alle erneut ab.

Im Studio öffnen

Details

Was Annotationen mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Graphics
Bereich
Visuals
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/annotations.tsx
Bringt mit
utilskitmotion-scene
npm-Pakete
Keine
Sprachen
Texte kommen über Props

Props

Die Schnittstelle ArrowProps aus components/ui/annotations.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von DrawProps an.

  • shapeoptional
    ArrowShape

    curveloopstraightswoopzigzag

  • flipoptional
    boolean

    Mirror left to right, so the arrow points the other way

  • rotateoptional
    number

    Turn the whole arrow, in degrees

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle label.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 369 Zeilen
components/ui/annotations.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView } from '@/lib/kit';
import { rand } from '@/components/ui/motion-scene';

/*
 * Hand-drawn marks for pointing at things: arrows, brackets, checks, strikes,
 * emphasis lines, numbered callouts and notes. They draw themselves in when
 * they scroll into view (or on hover), like a pen on paper.
 *
 * Circles, underlines and marker highlights around words live in the
 * highlight-marker element, these marks go with them.
 *
 * Every line has a slight, seeded wobble, so two arrows never look stamped.
 * The server renders the finished drawing, reduced motion keeps it there.
 */

export type MarkTone = 'accent' | 'foreground' | 'muted' | 'warning' | 'success' | 'destructive';
export type DrawTrigger = 'view' | 'hover' | 'load';

const TONE: Record<MarkTone, string> = {
  accent: 'text-[color-mix(in_oklab,var(--primary)_88%,var(--foreground))]',
  foreground: 'text-foreground',
  muted: 'text-muted-foreground',
  warning: 'text-warning-foreground',
  success: 'text-success-foreground',
  destructive: 'text-destructive',
};

interface DrawProps {
  tone?: MarkTone;
  /** When the line draws itself. Default view. */
  trigger?: DrawTrigger;
  /** Milliseconds before it starts */
  delay?: number;
  /** Milliseconds the line takes */
  duration?: number;
  seed?: number;
  strokeWidth?: number;
  className?: string;
  /** Text for screen readers, otherwise the mark is decoration */
  label?: string;
}

const STYLES = `
.kan-path{stroke-dasharray:1;stroke-dashoffset:0;transition:stroke-dashoffset var(--kan-dur,800ms) cubic-bezier(.45,.1,.25,1) var(--kan-delay,0ms)}
.kan-off .kan-path{stroke-dashoffset:1;transition:none}
.kan-hover:hover .kan-path,.kan-hover:focus-visible .kan-path{animation:kan-redraw var(--kan-dur,800ms) cubic-bezier(.45,.1,.25,1) var(--kan-delay,0ms) both}
@keyframes kan-redraw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
@media (prefers-reduced-motion:reduce){.kan-path{transition:none}.kan-off .kan-path{stroke-dashoffset:0}.kan-hover:hover .kan-path{animation:none}}
`;

/** Draw state: finished on the server, hidden then drawn in the browser */
function useDraw<T extends Element>(trigger: DrawTrigger) {
  const [ref, inView] = useInView<T>('0px 0px -12% 0px');
  const [hidden, setHidden] = useState(false);
  const started = useRef(false);
  useEffect(() => {
    if (trigger !== 'load' || prefersReducedMotion() || started.current) return;
    started.current = true;
    setHidden(true);
    const id = requestAnimationFrame(() => requestAnimationFrame(() => setHidden(false)));
    return () => cancelAnimationFrame(id);
  }, [trigger]);
  const off = trigger === 'view' ? !inView : hidden;
  return [ref, off] as const;
}

/** Points along a line with a hand wobble, as a smooth path */
function wobble(points: [number, number][], seed: number, amount = 1.6) {
  const pts = points.map(([x, y], i) => [x + (rand(seed, i) - 0.5) * amount, y + (rand(seed, i + 40) - 0.5) * amount] as [number, number]);
  let d = `M${round(pts[0][0], 1)} ${round(pts[0][1], 1)}`;
  for (let i = 0; i < pts.length - 1; i++) {
    const p0 = pts[Math.max(0, i - 1)];
    const p1 = pts[i];
    const p2 = pts[i + 1];
    const p3 = pts[Math.min(pts.length - 1, i + 2)];
    d += `C${round(p1[0] + (p2[0] - p0[0]) / 6, 1)} ${round(p1[1] + (p2[1] - p0[1]) / 6, 1)} ${round(p2[0] - (p3[0] - p1[0]) / 6, 1)} ${round(p2[1] - (p3[1] - p1[1]) / 6, 1)} ${round(p2[0], 1)} ${round(p2[1], 1)}`;
  }
  return d;
}

function Svg({ viewBox, off, hover, tone = 'accent', duration = 800, delay = 0, strokeWidth = 2.4, label, className, style, children, svgRef, stretch }: { stretch?: boolean; viewBox: string; off: boolean; hover?: boolean; tone?: MarkTone; duration?: number; delay?: number; strokeWidth?: number; label?: string; className?: string; style?: React.CSSProperties; children: React.ReactNode; svgRef: React.Ref<SVGSVGElement> }) {
  return (
    <>
      <style href="kit-annotations" precedence="default">
        {STYLES}
      </style>
      <svg
        ref={svgRef}
        viewBox={viewBox}
        preserveAspectRatio={stretch ? 'none' : undefined}
        role={label ? 'img' : undefined}
        aria-label={label}
        aria-hidden={label ? undefined : true}
        className={cn('overflow-visible', TONE[tone], off && 'kan-off', hover && 'kan-hover', className)}
        style={{ '--kan-dur': `${duration}ms`, '--kan-delay': `${delay}ms`, ...style } as React.CSSProperties}
        fill="none"
        stroke="currentColor"
        strokeWidth={strokeWidth}
        strokeLinecap="round"
        strokeLinejoin="round"
      >
        {children}
      </svg>
    </>
  );
}

/* ------------------------------------------------------------------ */
/* Arrow                                                               */
/* ------------------------------------------------------------------ */

export type ArrowShape = 'curve' | 'loop' | 'straight' | 'swoop' | 'zigzag';

export interface ArrowProps extends DrawProps {
  shape?: ArrowShape;
  /** Mirror left to right, so the arrow points the other way */
  flip?: boolean;
  /** Turn the whole arrow, in degrees */
  rotate?: number;
}

const ARROW: Record<ArrowShape, [number, number][]> = {
  curve: [[6, 52], [30, 22], [64, 12], [96, 18], [112, 30]],
  loop: [[6, 50], [34, 40], [52, 18], [40, 8], [30, 20], [46, 34], [80, 32], [112, 22]],
  straight: [[6, 32], [40, 30], [76, 31], [112, 30]],
  swoop: [[6, 10], [20, 40], [50, 54], [86, 46], [112, 26]],
  zigzag: [[6, 40], [26, 22], [44, 40], [62, 22], [82, 38], [112, 26]],
};

/**
 * A hand-drawn arrow that draws itself in. 120 × 60 at its natural size,
 * scale it with a width class.
 *   <Arrow shape="curve" className="w-28" />
 */
export function Arrow({ shape = 'curve', flip, rotate = 0, seed = 1, tone, trigger = 'view', delay = 0, duration = 900, strokeWidth, label, className }: ArrowProps) {
  const [ref, off] = useDraw<SVGSVGElement>(trigger);
  const pts = ARROW[shape];
  const end = pts[pts.length - 1];
  const prev = pts[pts.length - 2];
  const a = Math.atan2(end[1] - prev[1], end[0] - prev[0]);
  const head = (da: number) => [round(end[0] - Math.cos(a + da) * 13, 1), round(end[1] - Math.sin(a + da) * 13, 1)];
  const [h1x, h1y] = head(0.55);
  const [h2x, h2y] = head(-0.55);
  return (
    <Svg svgRef={ref} viewBox="0 0 120 60" off={off} hover={trigger === 'hover'} tone={tone} duration={duration} delay={delay} strokeWidth={strokeWidth} label={label} className={cn('h-auto w-[120px]', className)} style={{ transform: `${flip ? 'scaleX(-1) ' : ''}rotate(${rotate}deg)` }}>
      <path className="kan-path" pathLength={1} d={wobble(pts, seed)} />
      <path className="kan-path" pathLength={1} style={{ '--kan-delay': `${delay + duration * 0.85}ms`, '--kan-dur': '260ms' } as React.CSSProperties} d={`M${h1x} ${h1y}L${round(end[0], 1)} ${round(end[1], 1)}L${h2x} ${h2y}`} />
    </Svg>
  );
}

/* ------------------------------------------------------------------ */
/* Bracket, check, strike, emphasis                                    */
/* ------------------------------------------------------------------ */

/** A curly bracket that groups lines. Vertical by default, 24 × 120. */
export function Bracket({ side = 'left', seed = 2, tone, trigger = 'view', delay = 0, duration = 700, strokeWidth, label, className }: DrawProps & { side?: 'left' | 'right' }) {
  const [ref, off] = useDraw<SVGSVGElement>(trigger);
  const d = wobble([[20, 4], [12, 10], [12, 50], [4, 60], [12, 70], [12, 110], [20, 116]], seed, 1.2);
  return (
    <Svg svgRef={ref} viewBox="0 0 24 120" off={off} hover={trigger === 'hover'} tone={tone} duration={duration} delay={delay} strokeWidth={strokeWidth} label={label} className={cn('h-full w-6', className)} style={side === 'right' ? { transform: 'scaleX(-1)' } : undefined}>
      <path className="kan-path" pathLength={1} d={d} />
    </Svg>
  );
}

/** A quick tick, 28 × 24 */
export function Check({ seed = 3, tone = 'success', trigger = 'view', delay = 0, duration = 420, strokeWidth = 2.8, label, className }: DrawProps) {
  const [ref, off] = useDraw<SVGSVGElement>(trigger);
  return (
    <Svg svgRef={ref} viewBox="0 0 28 24" off={off} hover={trigger === 'hover'} tone={tone} duration={duration} delay={delay} strokeWidth={strokeWidth} label={label} className={cn('h-auto w-7', className)}>
      <path className="kan-path" pathLength={1} d={wobble([[3, 13], [10, 20], [25, 3]], seed, 1)} />
    </Svg>
  );
}

/** A cross for wrong or removed, 24 × 24 */
export function Cross({ seed = 4, tone = 'destructive', trigger = 'view', delay = 0, duration = 360, strokeWidth = 2.6, label, className }: DrawProps) {
  const [ref, off] = useDraw<SVGSVGElement>(trigger);
  return (
    <Svg svgRef={ref} viewBox="0 0 24 24" off={off} hover={trigger === 'hover'} tone={tone} duration={duration} delay={delay} strokeWidth={strokeWidth} label={label} className={cn('h-auto w-6', className)}>
      <path className="kan-path" pathLength={1} d={wobble([[4, 4], [20, 20]], seed, 1.2)} />
      <path className="kan-path" pathLength={1} style={{ '--kan-delay': `${delay + duration}ms` } as React.CSSProperties} d={wobble([[20, 4], [4, 20]], seed + 1, 1.2)} />
    </Svg>
  );
}

/** Strikes through its children with a hand line, e.g. an old price */
export function Strike({ children, seed = 5, tone = 'destructive', trigger = 'view', delay = 0, duration = 500, strokeWidth = 2.2, className }: DrawProps & { children: React.ReactNode }) {
  const [ref, off] = useDraw<SVGSVGElement>(trigger);
  return (
    <span className={cn('relative inline-block', className)}>
      {children}
      <Svg svgRef={ref} viewBox="0 0 100 20" off={off} hover={trigger === 'hover'} tone={tone} duration={duration} delay={delay} strokeWidth={strokeWidth} stretch className="pointer-events-none absolute left-0 top-1/2 h-[0.9em] w-full -translate-y-1/2">
        <path className="kan-path" pathLength={1} d={wobble([[2, 14], [40, 9], [70, 10], [98, 5]], seed, 1.4)} />
      </Svg>
    </span>
  );
}

/** Short lines bursting from a point, for "look here". 40 × 40, the lines fan to the upper right. */
export function Emphasis({ seed = 6, tone, trigger = 'view', delay = 0, duration = 320, strokeWidth = 2.4, rotate = 0, label, className }: DrawProps & { rotate?: number }) {
  const [ref, off] = useDraw<SVGSVGElement>(trigger);
  const rays: [number, number, number, number][] = [
    [6, 30, 4, 18],
    [12, 26, 20, 12],
    [18, 32, 32, 26],
  ];
  return (
    <Svg svgRef={ref} viewBox="0 0 40 40" off={off} hover={trigger === 'hover'} tone={tone} duration={duration} delay={delay} strokeWidth={strokeWidth} label={label} className={cn('h-auto w-10', className)} style={{ transform: `rotate(${rotate}deg)` }}>
      {rays.map(([x1, y1, x2, y2], i) => (
        <path key={i} className="kan-path" pathLength={1} style={{ '--kan-delay': `${delay + i * 90}ms` } as React.CSSProperties} d={wobble([[x1, y1], [x2, y2]], seed + i, 1)} />
      ))}
    </Svg>
  );
}

/* ------------------------------------------------------------------ */
/* Callout and note                                                    */
/* ------------------------------------------------------------------ */

/** A numbered pin for annotated screenshots, with a soft pulse */
export function Callout({ n, tone = 'accent', pulse = true, className, label }: { n: number | string; tone?: 'accent' | 'foreground'; pulse?: boolean; className?: string; label?: string }) {
  return (
    <span role={label ? 'img' : undefined} aria-label={label} className={cn('relative inline-grid size-7 shrink-0 place-items-center rounded-full text-[13px] font-semibold tabular-nums shadow-[0_0_0_3px_var(--background),var(--shadow-md)]', tone === 'accent' ? 'bg-primary text-primary-foreground' : 'bg-foreground text-background', className)}>
      {pulse && <span aria-hidden className={cn('absolute inset-0 animate-[kan-ping_2.4s_cubic-bezier(0,0,0.2,1)_infinite] rounded-full motion-reduce:animate-none', tone === 'accent' ? 'bg-primary' : 'bg-foreground')} />}
      <style href="kit-annotations-ping" precedence="default">{`@keyframes kan-ping{0%{transform:scale(1);opacity:.5}70%,100%{transform:scale(1.9);opacity:0}}`}</style>
      <span className="relative">{n}</span>
    </span>
  );
}

/** A small note in a hand-set way: tilted, on a paper card or bare */
export function Note({ children, tilt = -2, paper = false, className }: { children: React.ReactNode; tilt?: number; paper?: boolean; className?: string }) {
  return (
    <span
      className={cn(
        'inline-block max-w-[16rem] text-pretty text-[15px] font-medium leading-snug transition-transform duration-500 ease-[var(--ease-kit)] hover:rotate-0 motion-reduce:transition-none',
        paper
          ? 'rounded-md bg-[color-mix(in_oklab,var(--warning)_26%,var(--card))] px-3.5 py-2.5 text-foreground shadow-[var(--shadow-md)]'
          : 'text-[color-mix(in_oklab,var(--primary)_88%,var(--foreground))]',
        className,
      )}
      style={{ transform: `rotate(${tilt}deg)` }}
    >
      {children}
    </span>
  );
}

/* ------------------------------------------------------------------ */
/* Annotated image: numbered pins on a picture, notes beside it        */
/* ------------------------------------------------------------------ */

export interface AnnotatedImageNote {
  /** Position of the pin in percent of the picture, 0 to 100 */
  x: number;
  y: number;
  title: string;
  text?: string;
}

/**
 * A picture (screenshot, product, floor plan) with numbered pins and the notes
 * that explain them. Hover or focus a note and its pin grows, the others stop
 * pulsing. Notes sit beside the picture on desktops and under it below 1024 px.
 *   <AnnotatedImage notes={[{ x: 22, y: 21, title: 'Your workspace', text: '…' }]}><img … /></AnnotatedImage>
 */
export function AnnotatedImage({ notes, tone = 'accent', pulse = true, className, children }: { notes: AnnotatedImageNote[]; tone?: 'accent' | 'foreground'; pulse?: boolean; className?: string; children: React.ReactNode }) {
  const [hot, setHot] = useState<number | null>(null);
  return (
    <div className={cn('grid grid-cols-1 items-start gap-10 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:gap-14', className)}>
      <div className="relative">
        {children}
        {notes.map((n, i) => (
          <span key={i} className={cn('absolute -translate-x-1/2 -translate-y-1/2 transition-transform duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', hot === i && 'scale-125')} style={{ left: `${round(n.x)}%`, top: `${round(n.y)}%` }}>
            <Callout n={i + 1} tone={tone} pulse={pulse && (hot === null || hot === i)} />
          </span>
        ))}
      </div>
      <ol className="grid gap-2">
        {notes.map((n, i) => (
          <li key={`${n.title}${i}`}>
            <button
              type="button"
              onPointerEnter={() => setHot(i)}
              onPointerLeave={() => setHot(null)}
              onFocus={() => setHot(i)}
              onBlur={() => setHot(null)}
              className={cn('flex w-full gap-4 rounded-xl p-3 text-left outline-none transition-colors focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--ring)]', hot === i ? 'bg-accent' : 'hover:bg-accent/60')}
            >
              <Callout n={i + 1} pulse={false} tone={hot === i ? tone : 'foreground'} />
              <span className="min-w-0">
                <span className="block text-[15px] font-semibold text-foreground">{n.title}</span>
                {n.text && <span className="mt-1 block text-pretty text-[14px] leading-relaxed text-muted-foreground">{n.text}</span>}
              </span>
            </button>
          </li>
        ))}
      </ol>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Marked list: a checklist marked by hand                             */
/* ------------------------------------------------------------------ */

export interface MarkedListItem {
  text: string;
  /** done gets a check and a strike, dropped a cross, open stays empty */
  state?: 'done' | 'open' | 'dropped';
}

/**
 * A checklist marked by hand: checks and strikes draw in one after another as
 * it scrolls into view. A bracket with a note can group the first items.
 *   <MarkedList title="Launch" items={[{ text: 'Sign off the copy', state: 'done' }, …]} group={{ label: 'Before launch', count: 3 }} />
 */
export function MarkedList({ title, items, group, step = 260, className }: { title?: string; items: MarkedListItem[]; group?: { label: string; count: number }; step?: number; className?: string }) {
  const done = items.filter((i) => i.state === 'done').length;
  const rows = Math.min(items.length, group?.count ?? 0);
  return (
    <div className={cn('w-full', className)}>
      {title && (
        <div className="mb-6 flex items-baseline justify-between gap-4">
          <h3 className="text-balance text-[clamp(1.5rem,1.2rem+1vw,2rem)] font-semibold tracking-[-0.02em] text-foreground">{title}</h3>
          <span className="shrink-0 text-[14px] tabular-nums text-muted-foreground">
            {done} / {items.length}
          </span>
        </div>
      )}
      <div className={cn('relative', group && rows > 0 && 'sm:pl-14')}>
        {group && rows > 0 && (
          <>
            <span className="absolute left-6 top-1 hidden sm:flex" style={{ height: `calc(${rows} * 3.5rem - 0.5rem)` }}>
              <Bracket className="h-full" />
            </span>
            <span className="absolute left-0 hidden -translate-y-1/2 rotate-180 whitespace-nowrap text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_88%,var(--foreground))] [writing-mode:vertical-rl] sm:block" style={{ top: `calc(${rows} * 1.75rem)` }}>
              {group.label}
            </span>
          </>
        )}
        <ul>
          {items.map((it, i) => (
            <li key={`${it.text}${i}`} className="flex h-14 items-center gap-4 border-b border-border last:border-b-0">
              <span className="grid size-7 shrink-0 place-items-center rounded-md border border-border-strong bg-background">
                {it.state === 'done' && <Check className="w-5" delay={i * step} />}
                {it.state === 'dropped' && <Cross className="w-4" delay={i * step} />}
              </span>
              <span className={cn('min-w-0 truncate text-[15px]', it.state === 'done' || it.state === 'dropped' ? 'text-muted-foreground' : 'text-foreground')}>
                {it.state === 'done' ? (
                  <Strike tone="muted" delay={i * step + step} duration={420}>
                    {it.text}
                  </Strike>
                ) : (
                  it.text
                )}
              </span>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

Häufige Fragen

Wie installiere ich Annotationen?

Mit aiw add @ai-website/annotations. Der Befehl legt die Datei components/ui/annotations.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind motion-scene. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Annotationen?

5 Varianten, Hero mit Hinweis, Kommentierter Screenshot, Preise markiert, Checkliste und Alle Markierungen.

Kann ein KI-Assistent Annotationen einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Annotationen mit aiw_components und installieren sie mit aiw_add @ai-website/annotations. Nenn im Prompt die Variante, etwa variant="circle", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Annotationen ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Note-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert