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
Variants
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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
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.
shapeoptionalArrowShapecurveloopstraightswoopzigzag
flipoptionalbooleanMirror left to right, so the arrow points the other way
rotateoptionalnumberTurn 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
'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.