Skip to content
UI Kit
Accent color

Annotations

Hand-drawn marks that point at things. Arrows in five shapes, brackets, checks, crosses, strikes, emphasis lines, numbered callouts and notes draw themselves in as they scroll into view, each with its own slight wobble.

One of 5 components in Graphics

5 variants

Variants

Hero callout

A circle draws around the key word, a note and a curled arrow point at the main button. On phones the note sits above the button and the arrow points down.

Open in Studio

Annotated screenshot

Numbered pins on a screenshot with notes beside it. Hover or focus a note and its pin grows while the others stop pulsing. Phones put the notes under the picture.

Open in Studio

Pricing marks

The old price is struck through, the features get their checks one after another and a note with an arrow names the plan most people take.

Open in Studio

Checklist

A launch list where checks and strikes draw in one after another as it scrolls into view, a cross for what was dropped and a bracket that groups the first part.

Open in Studio

All marks

Every mark of the set on one sheet, with its code. Draw again replays them all.

Open in Studio

Details

What Annotations brings along, read from the registry and the source.

Category
Graphics
Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/annotations.tsx
Brings along
utilskitmotion-scene
npm packages
None
Languages
Texts come through props

Props

The ArrowProps interface from components/ui/annotations.tsx, with the comments from the code. Also takes every prop of DrawProps.

  • shapeoptional
    ArrowShape

    curveloopstraightswoopzigzag

  • flipoptional
    boolean

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

  • rotateoptional
    number

    Turn the whole arrow, in degrees

Accessibility

Read from the source of the component.

  • Sets aria-label, so screen readers announce purpose and state.
  • Uses the role label.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 369 lines
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>
  );
}

Frequent questions

How do I install Annotations?

With aiw add @ai-website/annotations. The command puts components/ui/annotations.tsx into your project, the code is yours from then on. It brings along motion-scene. It needs no further npm packages.

Which variants does Annotations have?

5 variants, Hero callout, Annotated screenshot, Pricing marks, Checklist and All marks.

Can an AI assistant build with Annotations?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Annotations with aiw_components and install it with aiw_add @ai-website/annotations. Name the variant in your prompt, for example variant="circle", and the assistant writes your content straight into the props.

Can I adjust Annotations without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Note code to copy.

Often searched

Jump to

Copied to the clipboard