# NDA badge

> A View Work pill whose eye follows the pointer, and shuts when the work is under NDA.

The pill that sits on a project card. Its eye watches the pointer wherever it goes on the page; hover or focus a locked one and the eye closes, the pill blushes red, and its words change to Under NDA. Both labels share one cell, so the pill never changes width.

A React component by Bruno Delorence: https://brunodelorence.com/components/nda-badge. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Feedback.

## Anatomy

1. **Stage**: A 16:9 surface, never under 260 px tall. A container query draws the pill’s 13 px type up to 38 px from its width.
2. **Pill**: A button, or a link when the work is open and has an `href`. Its sizes are in em, so it scales as one piece.
3. **Eye**: An open eye and a shut one in one box. They swap by squashing and fading, 240 ms, so no drawing is restyled.
4. **Pupil**: A disc of the stage’s colour. It leans up to about 2 px towards the pointer anywhere on the page, fully within 200 px and less out to 300 px.
5. **Label**: The pill’s words. Both labels share one grid cell, so the pill keeps the width of the longer.
6. **Pill under NDA**: Hovered or focused, a locked pill blushes red in 220 ms. It stays `aria-disabled` and says why when pressed.
7. **Shut eye**: One curve in the red ink, stroked 2.5 units round. It opens again when the pointer or the focus leaves.
8. **NDA label**: Slides up into the place of the label as it slides out. Screen readers hear both words at once, in the pill’s name.

## When to use

- A project card in a portfolio or a case list, where some of the work can’t be shown.
- Telling a visitor why a card won’t open at the moment they reach for it, not after a click.
- A grid that mixes open and locked work: the same pill on every card, only the locked ones shut.
- A playful page where a watching eye fits the tone and invites the pointer over.

## When not to use

- Real access control. The pill only says the work is private; protect the work itself on the server.
- Work behind a login or a password. Use a sign-in button that says so, and lets the person in.
- A dense table or a list of many rows: dozens of eyes following the pointer are noise. Use a quiet lock icon or a `Private` tag.
- A sober product UI (billing, settings, admin). Use a plain button and a disabled state with a tooltip.
- Touch-first pages alone: with no hover, the shut eye shows only for a moment after a tap.

## Installation

Install it with the shadcn CLI, into components/nda-badge of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/nda-badge.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { NdaBadge } from './nda-badge';

export function ProjectCover({ locked }: { locked: boolean }) {
  return <NdaBadge locked={locked} href="/work/atlas" autoplay={false} />;
}
```

## States

- **Locked, at rest**: Looks like any open card’s pill: the work is under NDA, but nothing says so yet.
- **Watching**: The pupil leans towards the pointer, here pinned with `gaze`. Under reduced motion it stays in the middle.
- **Under NDA**: Hovered, focused or tapped: the eye shuts and the pill turns red. A tap holds it for 1.6 s.
- **Open work**: With `locked={false}` it never shuts: a link with `href`, or a button that calls `onOpen`.
- **Dark, at rest**: Every colour is a `light-dark()` token: the page’s scheme, or the one `colorScheme` sets.
- **Dark, under NDA**: The red is lifted for the dark stage, on a deep wine fill.

## How it works

- The eye is HTML layers around two still drawings: the open eye with its pupil (a disc of its own), and the shut eye, a single curve. The pupil moves by a transform and the eyes swap by squashing and fading their boxes, so no SVG is ever restyled.
- The pupil leans towards the pointer anywhere on the page, up to about 2 px: fully within 200 px of the eye, less and less out to 300 px. It is worked out once per frame while a mouse moves (never for touch) and eases there over 130 ms.
- Hovering, focusing or tapping a locked pill shuts the eye: the open eye flattens and fades, the shut one opens out 50 ms later, and the colours cross over in 220 ms.
- Both labels sit in the same grid cell, so the pill keeps the width of the longer one; one slides up and out as the other slides in.
- The demo keeps a beat: the pointer reaches a new stop every 693 ms in a 440 ms flight the compositor plays, and the pupil plays its own animation through the same points on the same clock.
- Every colour is a light-dark() token on the root, so the badge follows the page’s color-scheme, or the colorScheme prop. At night the eye turns light with a near-black pupil, and the NDA blush becomes a deep wine with a coral-red label.

## API reference

- **locked** (`boolean`, default `true`): The work is under NDA: hovering or focusing the pill shuts the eye and says so.
- **label** (`string`, default `'View Work'`): The pill’s words.
- **lockedLabel** (`string`, default `'Under NDA'`): What a locked pill says when hovered or focused.
- **defaultClosed** (`boolean`, default `false`): A locked pill starts shut, as if hovered, until the pointer or the focus leaves it. Turns the demo off.
- **gaze** (`{ x: number; y: number }`): Pins the pupil, looking at a point this many px from the eye’s middle (at the pill’s 13 px type); it then ignores the pointer, and the demo is off.
- **href** (`string`): Where an open pill leads; without it, an open pill is a button that calls onOpen.
- **onOpen** (`() => void`): Called when an open pill without a link is pressed.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer wanders around the pill and hovers it.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): Extra class for the root.

## Accessibility

- An open pill with an href is a link, and without one a button. A locked pill is a button with aria-disabled, named with both labels (“View Work, Under NDA”).
- Focus shuts the eye just as hover does, with a visible focus ring. Pressing a locked pill (key, click or tap) announces why it doesn’t open, in a polite live region.
- The eye is decorative (aria-hidden), and the visible label swap is hidden from screen readers, which hear the pill’s name instead.
- Under reduced motion the pupil stays centred, the swaps are instant and the demo never plays.

## Source

### nda-badge/nda-badge.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './nda-badge.module.css';

/*
 * NDA badge: the "View Work" pill of a project card. Its eye watches the pointer; on work under
 * a non-disclosure agreement, hovering or focusing the pill shuts the eye and the pill turns red:
 * "Under NDA".
 *
 * - The eye is drawn in HTML layers around two still drawings: the open eye with its pupil, a
 *   round box of its own, and the shut eye, a single curve. The pupil moves by a transform, and
 *   the eyes swap by squashing and fading their boxes, so no SVG is ever restyled.
 * - The pupil leans towards the pointer, wherever it is on the page: up to about 2 px, all the way
 *   within 200 px of the eye and less and less out to 300 px. It is computed once per frame while
 *   the pointer moves (never for touch), and eases there with a short transition.
 * - Both labels sit in the same grid cell, so the pill keeps the width of the longer one. One
 *   slides up and out as the other slides in.
 * - While on screen and untouched, a demo pointer wanders around the pill, the eye following it on
 *   the same clock as its flight, and hovers it now and then.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

/** How far the pupil can lean, in the drawing's units (the eye is 30 units wide). */
const REACH = 3.2;
/** Within NEAR px of the eye the pupil leans all the way; from there out to FAR, less and less. */
const NEAR = 200;
const FAR = 300;
/** Px of distance per unit of lean: the pupil leans less for a pointer close by. */
const PULL = 28;
/** The eye's width in px at the badge's own size (13 px type); larger type scales it all. */
const EYE = 16.25;

/** The demo keeps a beat: the pointer reaches a new stop every BEAT ms. */
const BEAT = 693;
/** How long each flight between stops takes; the rest of the beat it stands still. */
const FLIGHT = 440;
/**
 * The demo's stops, around the pill: offsets from its middle in widths and heights of the pill,
 * each held for a number of beats. Over the pill, the pointer is the arrow or the hand.
 */
const PATH: { x: number; y: number; over?: 'arrow' | 'hand'; beats: number }[] = [
  { x: 0.22, y: 1.93, beats: 1 },
  { x: 0.82, y: 1.27, beats: 1 },
  { x: -1.01, y: 0.47, beats: 1 },
  { x: -0.37, y: 2.47, beats: 1 },
  { x: 0.12, y: 0.87, beats: 1 },
  { x: 0.03, y: 0.13, over: 'arrow', beats: 1 },
  { x: 0.05, y: 1.33, beats: 1 },
  { x: 0.05, y: 1.07, beats: 1 },
  { x: 0.05, y: 0.07, over: 'hand', beats: 2 },
  { x: 0.01, y: 1.13, beats: 1 },
  { x: 0.12, y: 0.5, over: 'hand', beats: 2 },
  { x: -0.05, y: 2.4, beats: 1 },
  { x: 0.37, y: 1.6, beats: 1 },
];

/**
 * Where the pupil leans, in px of the eye's own box, for a pointer `dx`, `dy` px from the eye's
 * middle. `size` is how much larger than its own 13 px the badge is drawn.
 */
function lean(dx: number, dy: number, size: number): Point {
  const distance = Math.hypot(dx, dy) / size;
  if (!distance) return { x: 0, y: 0 };
  const proximity = Math.min(1, Math.max(0, (FAR - distance) / (FAR - NEAR)));
  // From the drawing's units to px: the 30-unit drawing is EYE px wide.
  const reach = Math.min(REACH, distance / PULL) * proximity * (EYE / 30) * size;
  const length = Math.hypot(dx, dy);
  return { x: (dx / length) * reach, y: (dy / length) * reach };
}

const toTransform = ({ x, y }: Point) => `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px)`;

export function NdaBadge({
  locked = true,
  label = 'View Work',
  lockedLabel = 'Under NDA',
  href,
  onOpen,
  autoplay = true,
  defaultClosed = false,
  gaze,
  colorScheme,
  className = '',
}: {
  /** The work is under NDA: hovering or focusing the pill shuts the eye and says so. */
  locked?: boolean;
  /** The pill's words. */
  label?: string;
  /** What a locked pill says when hovered or focused. */
  lockedLabel?: string;
  /** Where an open pill leads; without it, an open pill is a button that calls onOpen. */
  href?: string;
  /** Called when an open pill without a link is pressed. */
  onOpen?: () => void;
  /** While on screen and untouched, a demo pointer wanders around the pill and hovers it. */
  autoplay?: boolean;
  /** A locked pill starts shut, as if hovered, until the pointer or the focus leaves it. The demo is off. */
  defaultClosed?: boolean;
  /**
   * Pins the pupil, looking at a point this many px from the eye's middle (at the pill's own 13 px
   * type): it then ignores the pointer, and the demo is off.
   */
  gaze?: Point;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(4000);
  /** Shut by the reader's pointer, focus or tap; and by the demo's pointer. */
  const [closed, setClosed] = useState(defaultClosed);
  const [demoClosed, setDemoClosed] = useState(false);
  const [message, setMessage] = useState('');
  const badge = useRef<HTMLElement>(null);
  const eye = useRef<HTMLSpanElement>(null);
  const pupil = useRef<HTMLSpanElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  /** The demo moves the pupil: the reader's pointer leaves it alone meanwhile. */
  const demoOn = useRef(false);
  /** Points the pupil at the reader's pointer again, once the demo lets go of it. */
  const relook = useRef<() => void>(null);
  /** Keeps a tapped pill shut a moment, so a touch shows it too. */
  const tapTimer = useRef(0);
  /** A mouse or a pen is over the pill (a finger leaves no hover behind). */
  const hovering = useRef(false);

  const isClosed = locked && (closed || demoClosed);
  const demo = autoplay && playing && !defaultClosed && !gaze;
  const gazeX = gaze?.x;
  const gazeY = gaze?.y;
  /** A pinned gaze: the reader's pointer leaves the pupil alone. */
  const pinned = useRef(false);
  pinned.current = gazeX !== undefined && gazeY !== undefined;

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  /** How much larger than 13 px the pill is drawn, from the eye's size. */
  const sizeOf = (element: HTMLElement) => element.offsetWidth / EYE || 1;

  // The pupil follows the reader's pointer, anywhere on the page, while the pill is near the
  // screen. One frame computes it, however many moves come in between.
  useEffect(() => {
    const element = root.current;
    const box = eye.current;
    const dot = pupil.current;
    if (!element || !box || !dot) return;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
    let frame = 0;
    let near = false;
    let at: Point | null = null;
    const reset = () => {
      dot.style.transform = 'translate(0px, 0px)';
    };
    const look = () => {
      frame = 0;
      if (demoOn.current || pinned.current) return;
      if (!at || reduced.matches) return reset();
      const rect = box.getBoundingClientRect();
      // Screen px to the pill's own, should it sit in a scaled container.
      const scale = rect.width / box.offsetWidth || 1;
      const dx = (at.x - rect.left - rect.width / 2) / scale;
      const dy = (at.y - rect.top - rect.height / 2) / scale;
      dot.style.transform = toTransform(lean(dx, dy, sizeOf(box)));
    };
    const follow = (event: PointerEvent) => {
      if (event.pointerType === 'touch' || !near) return;
      at = { x: event.clientX, y: event.clientY };
      if (!frame) frame = requestAnimationFrame(look);
    };
    const stop = () => {
      at = null;
      cancelAnimationFrame(frame);
      frame = 0;
      if (!demoOn.current && !pinned.current) reset();
    };
    relook.current = () => {
      if (!frame) frame = requestAnimationFrame(look);
    };
    const observer = new IntersectionObserver(
      ([entry]) => {
        near = entry.isIntersecting;
        if (!near) stop();
      },
      { rootMargin: '300px 0px' },
    );
    observer.observe(element);
    document.addEventListener('pointermove', follow, { passive: true });
    document.documentElement.addEventListener('pointerleave', stop);
    window.addEventListener('blur', stop);
    reduced.addEventListener('change', stop);
    return () => {
      cancelAnimationFrame(frame);
      relook.current = null;
      observer.disconnect();
      document.removeEventListener('pointermove', follow);
      document.documentElement.removeEventListener('pointerleave', stop);
      window.removeEventListener('blur', stop);
      reduced.removeEventListener('change', stop);
    };
  }, [root]);

  // A pinned gaze: the pupil leans as it would for a pointer there.
  useEffect(() => {
    const box = eye.current;
    const dot = pupil.current;
    if (!box || !dot || gazeX === undefined || gazeY === undefined) return;
    const size = sizeOf(box);
    dot.style.transform = toTransform(lean(gazeX * size, gazeY * size, size));
    return () => {
      dot.style.transform = 'translate(0px, 0px)';
      relook.current?.();
    };
  }, [gazeX, gazeY]);

  useEffect(() => () => window.clearTimeout(tapTimer.current), []);

  const onEnter = (event: ReactPointerEvent) => {
    if (event.pointerType === 'touch') return;
    hovering.current = true;
    setClosed(true);
  };
  const onLeave = (event: ReactPointerEvent) => {
    if (event.pointerType === 'touch') return;
    hovering.current = false;
    if (!event.currentTarget.matches(':focus-visible')) setClosed(false);
  };

  const onPress = () => {
    if (locked) {
      // A tap shuts the eye a moment too; a key or a click says why.
      setClosed(true);
      window.clearTimeout(tapTimer.current);
      tapTimer.current = window.setTimeout(() => {
        if (!hovering.current && !badge.current?.matches(':focus')) setClosed(false);
      }, 1600);
      announce(`${lockedLabel}: this work can't be shown.`);
    } else onOpen?.();
  };

  // The demo: the pointer wanders around the pill, the pupil on its heels, and hovers it now and
  // then, which shuts the eye of a locked pill.
  const demoHover = useEffectEvent((hover: boolean) => setDemoClosed(hover));
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    const box = eye.current;
    const dot = pupil.current;
    const pill = badge.current;
    if (!demo || !stage || !arrow || !box || !dot || !pill) return;
    demoOn.current = true;
    let following: Animation | null = null;

    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      for (;;) {
        for (const step of PATH) {
          const start = performance.now();
          // The pill's place in the stage's px, read afresh: the stage may have been resized.
          const frame = stage.getBoundingClientRect();
          const scale = frame.width / stage.offsetWidth || 1;
          const rect = pill.getBoundingClientRect();
          const eyeRect = box.getBoundingClientRect();
          const middle = {
            x: (rect.left + rect.width / 2 - frame.left) / scale,
            y: (rect.top + rect.height / 2 - frame.top) / scale,
          };
          const eyeAt = {
            x: (eyeRect.left + eyeRect.width / 2 - frame.left) / scale,
            y: (eyeRect.top + eyeRect.height / 2 - frame.top) / scale,
          };
          const size = sizeOf(box);
          const to = {
            x: middle.x + (step.x * rect.width) / scale,
            y: middle.y + (step.y * rect.height) / scale,
          };
          if (!step.over) {
            demoHover(false);
            glyph('arrow');
          }
          await glide(to, FLIGHT, ({ points, timing }) => {
            // The pupil plays its own flight, on the same clock as the pointer's.
            const leans = points.map((point) => lean(point.x - eyeAt.x, point.y - eyeAt.y, size));
            following?.cancel();
            dot.style.transform = toTransform(leans[leans.length - 1]);
            following = dot.animate(
              leans.map((at) => ({ transform: toTransform(at) })),
              timing,
            );
          });
          if (step.over) {
            glyph(step.over);
            demoHover(true);
          }
          await wait(Math.max(0, start + step.beats * BEAT - performance.now()));
        }
      }
    });
    return () => {
      stop();
      following?.cancel();
      demoOn.current = false;
      dot.style.transform = 'translate(0px, 0px)';
      relook.current?.();
      setDemoClosed(false);
    };
  }, [demo, root]);

  const content = (
    <>
      <span ref={eye} className={styles.eye} data-part="eye" aria-hidden="true">
        <span className={styles.open} data-part="eye-open">
          <svg className={styles.drawing} viewBox="0 0 30 24" focusable="false">
            <path d="M1 12C7-1 23-1 29 12C23 25 7 25 1 12Z" />
          </svg>
          <span ref={pupil} className={styles.pupil} data-part="pupil" />
        </span>
        <span className={styles.shut} data-part="eye-shut">
          <svg className={styles.drawing} viewBox="0 0 30 24" focusable="false">
            <path d="M3.5 7.5C6 20 24 20 26.5 7.5" />
          </svg>
        </span>
      </span>
      <span className={styles.label} data-part="label" aria-hidden="true">
        <span className={styles.labelOpen} data-part="label-open">
          {label}
        </span>
        {locked && (
          <span className={styles.labelShut} data-part="label-shut">
            {lockedLabel}
          </span>
        )}
      </span>
    </>
  );

  const shared = {
    className: styles.badge,
    'data-part': 'pill',
    'data-closed': isClosed,
    'aria-label': locked ? `${label}, ${lockedLabel}` : label,
    onPointerEnter: locked ? onEnter : undefined,
    onPointerLeave: locked ? onLeave : undefined,
    onFocus: locked ? () => setClosed(true) : undefined,
    onBlur: locked ? () => setClosed(false) : undefined,
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        // The reader's own pointer in the stage takes over from the demo's.
        if (event.pointerType !== 'touch') take();
      }}
    >
      <div className={styles.scene}>
        {href && !locked ? (
          <a
            ref={(element) => {
              badge.current = element;
            }}
            href={href}
            {...shared}
          >
            {content}
          </a>
        ) : (
          <button
            ref={(element) => {
              badge.current = element;
            }}
            type="button"
            aria-disabled={locked || undefined}
            onClick={onPress}
            {...shared}
          >
            {content}
          </button>
        )}
      </div>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### nda-badge/nda-badge.module.css

```css
/* NDA badge (nda-badge.tsx): a white stage with the pill in its middle, or at night a near-black
   one with the pill a step lighter. The pill's sizes are in em of its 13 px type, so the stage can
   draw it larger from its own width (a container query).

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme).

   Memory. The stage is one layout boundary, sized from outside (its width and its proportions),
   so nothing inside it lays out the page. The pill paints on a small layer of its own, so its
   colour change never repaints the page around it; the pupil and the demo pointer move by
   transforms. */

.root {
  /* The stage, and the pill on it. */
  --surface: #fff;
  --surface-2: #f3f3f3;
  /* The words and the open eye. */
  --ink: #17191d;
  /* The pupil: the stage seen through the eye, white by day and near-black at night. */
  --pupil: #fff;
  /* The stage's edge, and the pill's. */
  --line: rgb(0 0 0 / 0.06);
  --edge: rgb(17 17 17 / 0.05);
  /* The pill under NDA, blushed red, and its words and shut eye. */
  --accent: #fff1f2;
  --accent-ink: #dc454c;
  /* The pill's lift, open and under NDA. */
  --shadow: rgb(0 0 0 / 0.07);
  --accent-shadow: rgb(80 10 15 / 0.05);
  /* The same on both schemes: the focus ring, and the demo's pointer, which looks like the
     system's. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  position: relative;
  container-type: inline-size;
  contain: size layout;
  /* Its width comes from outside; its height from the width, but never under 260px. */
  width: 100%;
  aspect-ratio: 16 / 9;
  min-height: 260px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#fff, #121214);
    --surface-2: light-dark(#f3f3f3, #232326);
    --ink: light-dark(#17191d, #ececef);
    --pupil: light-dark(#fff, #121214);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --edge: light-dark(rgb(17 17 17 / 0.05), rgb(255 255 255 / 0.08));
    --accent: light-dark(#fff1f2, #3a1b1e);
    --accent-ink: light-dark(#dc454c, #ff6b70);
    --shadow: light-dark(rgb(0 0 0 / 0.07), rgb(0 0 0 / 0.4));
    --accent-shadow: light-dark(rgb(80 10 15 / 0.05), rgb(0 0 0 / 0.4));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.scene {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: clamp(13px, 3.95cqi, 38px);
}

/* The pill. */
.badge {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.2em;
  box-sizing: border-box;
  min-height: 2.27em;
  margin: 0;
  padding: 0.45em 0.66em 0.45em 0.48em;
  border: 1px solid var(--edge);
  border-radius: 999px;
  background: var(--surface-2);
  box-shadow: 0 0.154em 0.615em var(--shadow);
  color: var(--ink);
  font:
    650 1em/1.2 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Arial,
    sans-serif;
  letter-spacing: -0.035em;
  text-decoration: none;
  -webkit-font-smoothing: antialiased;
  cursor: pointer;
  will-change: transform;
  transition:
    color 220ms ease,
    background-color 220ms ease,
    box-shadow 220ms ease;
}
.badge[data-closed='true'] {
  color: var(--accent-ink);
  background: var(--accent);
  box-shadow: 0 0.154em 0.615em var(--accent-shadow);
}
.badge:focus {
  outline: none;
}
.badge:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 4px;
}

/* The eye: the open one with its pupil, and the shut one, in one box. */
.eye {
  position: relative;
  display: block;
  flex-shrink: 0;
  width: 1.25em;
  height: 1em;
}
.open,
.shut {
  position: absolute;
  inset: 0;
  display: block;
  transition:
    transform 240ms cubic-bezier(0.22, 1, 0.36, 1),
    opacity 160ms ease;
}
.drawing {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.open .drawing {
  fill: var(--ink);
}
.shut {
  opacity: 0;
  transform: scaleY(0.25);
}
.shut .drawing {
  fill: none;
  stroke: var(--accent-ink);
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.badge[data-closed='true'] .open {
  opacity: 0;
  transform: scaleY(0.05);
}
.badge[data-closed='true'] .shut {
  opacity: 1;
  transform: scaleY(1);
  transition-delay: 50ms;
}
/* The pupil: a disc of the stage's colour, a third of the eye wide, in its middle. */
.pupil {
  position: absolute;
  top: calc(50% - 0.192em);
  left: calc(50% - 0.192em);
  width: 0.383em;
  height: 0.383em;
  border-radius: 50%;
  background: var(--pupil);
  transform: translate(0px, 0px);
  transition: transform 130ms cubic-bezier(0.22, 1, 0.36, 1);
}

/* The words: both in one cell, so the pill keeps the width of the longer. */
.label {
  display: grid;
}
.labelOpen,
.labelShut {
  grid-area: 1 / 1;
  white-space: nowrap;
  transition:
    opacity 150ms ease,
    transform 220ms ease;
}
.labelShut {
  opacity: 0;
  transform: translateY(4px);
}
.badge[data-closed='true'] .labelOpen {
  opacity: 0;
  transform: translateY(-4px);
}
.badge[data-closed='true'] .labelShut {
  opacity: 1;
  transform: translateY(0);
}

/* The demo pointer, over everything; one of its glyphs shows at a time. */
.pointerLayer {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.pointer {
  width: clamp(22px, 6.2cqi, 56px);
  height: clamp(22px, 6.2cqi, 56px);
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  will-change: opacity;
}

@media (prefers-reduced-motion: reduce) {
  .badge,
  .open,
  .shut,
  .labelOpen,
  .labelShut,
  .pupil {
    transition: none;
  }
}
```

### nda-badge/demo-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it (the eye of the
 * badge, which plays its own animation on the same clock). The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

export type Point = { x: number; y: number };

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const glide: Demo['glide'] = (to, ms, along) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
        y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (nda-badge.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over the badge. It is
 * decorative: what it does shows on the badge itself. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### nda-badge/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```