# Tactile button

> A glossy violet key that sinks onto its slab when pressed and keeps your fingerprints.

A physical-looking key: a violet face with rims of light, standing on a darker slab. Press it and the face slides down onto the slab, narrows a hair and dims; let go and it springs back. Every press, by pointer, touch or key, leaves a fingerprint where it landed, so the face slowly smudges.

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

Category: Inputs.

## Anatomy

1. **Stage**: The panel the key sits on, pale lilac or near-black, 16 by 9 and a size container: the key’s type is 4.12% of its width.
2. **Key**: The real `<button>`: the face in flow and the slab under it. Every size is in v, a 40th of its type.
3. **Face**: A violet vertical gradient, 108 v tall with a 20 v radius. Pressed, it slides down the slab’s depth and narrows by 2%.
4. **Slab**: The key’s darker side, 10 v lower than the face, with a lavender lip and a drop shadow that tightens on the press.
5. **Label**: The words, white with a soft violet shadow, tracked out a little and never wrapped.
6. **Fingerprint**: Each press leaves one where it landed, turned and sized its own way: faint while held, settled when let go.

## When to use

- The one action a playful screen is built around: start, join, roll, take action. It is meant to be pressed and enjoyed.
- A landing hero or a game screen where the press itself is the moment: the face sinks onto its slab in 70 ms and comes back in 110 ms.
- A touch-first surface that wants a physical key: Space and Enter press it for as long as they are held, like a finger.
- A demo that should show itself off: on screen and untouched, a pointer taps it and smudges it with prints, then wipes it clean.

## When not to use

- Everyday actions in a form, a toolbar or a dialog. Use a plain button; a 3D key on every action stops meaning anything.
- Several actions side by side. Use a primary and a secondary button; two glossy keys compete for the same press.
- Serious or destructive actions (pay, delete, sign). Use a standard button with a confirmation; fingerprints read as a game.
- A toggle that stays on. Use a switch or a toggle button; this key always comes back up when it is let go.
- A small inline control. The key scales with its stage (type 4.12% of its width, never under 16 px) and wants room around it.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/tactile-button.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 { TactileButton } from './tactile-button';

export function Confirm({ onConfirm }: { onConfirm: () => void }) {
  return <TactileButton label="take action" onPress={onConfirm} autoplay={false} />;
}
```

## States

- **Rest**: Up on its slab, clean, with the demo off: `autoplay={false}`.
- **Pressed**: The face down on the slab, 2% narrower, the gloss dimmed and its top shaded; held with `defaultPressed`.
- **Smudged**: Eight presses in: `defaultPrints={8}`. Past `maxPrints`, the oldest print fades away.
- **Disabled**: Greyed out and dimmed: it no longer sinks, takes prints or calls `onPress`.
- **Longer label**: The key grows with its words; the face keeps 58.5 v of room on each side.
- **Light**: `colorScheme="light"`: the lilac stage, whatever the page.
- **Dark**: `colorScheme="dark"`, or a dark page: a near-black stage and a deeper shadow.

## How it works

- The key is three CSS layers: the slab (its side, a darker violet 10 units deep under the face), the face with a vertical gradient, and a gloss of inset shadows: a bright rim along the top and the bottom, a soft glow inside each, and a faint one at the sides.
- Every size is in one unit, a 40th of the label’s type, and the type is 4.12 % of the stage’s width (a container query, never under 16 px), so the key keeps the same share of its stage at any width.
- Pressing slides the face down by the slab’s whole depth and narrows it by 1.8 %, in 80 ms; the gloss dims, the top of the face takes a shade from the slab’s edge, and the drop shadow tightens. Letting go plays it back in 110 ms, with an ease-out and no overshoot. Only transform, opacity and the slab’s small shadow change.
- A press leaves a fingerprint where it landed: ridges drawn once as SVG paths from a seeded generator (loops of wandering radius, open at the core, broken into short strokes), on a soft white haze, faded at the edge by a radial mask. Each print is turned and sized a little differently. It is faint while the finger is down and settles in as the key comes back up.
- The face keeps the newest prints (14 by default); older ones fade out. The prints lie over the words, as they would on glass.
- Space and Enter press the key for as long as they are held, and leave a print somewhere on the face, like a finger would.
- The demo moves a pointing hand from spot to spot on the key, pressing each for 130 to 700 ms, then wipes the face clean and starts again. It pauses offscreen, in a hidden tab and under reduced motion, and any input of the reader’s ends it.
- Every colour is a light-dark() token on the root, so the key follows the page’s color-scheme, or the colorScheme prop: the lilac stage turns near-black and the slab deepens.

## API reference

- **label** (`string`, default `'take action'`): The words on the key.
- **onPress** (`() => void`): Called when the key is pressed: a click, a tap, Enter or Space.
- **disabled** (`boolean`, default `false`): Greys the key out: it no longer sinks, takes prints or calls onPress, and the demo is off.
- **fingerprints** (`boolean`, default `true`): Each press leaves a fingerprint on the face.
- **maxPrints** (`number`, default `14`): How many prints the face keeps; older ones fade away.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer presses the key here and there and smudges it.
- **defaultPressed** (`boolean`, default `false`): Opens with the key held down, until it is pressed and let go. Turns the demo off.
- **defaultPrints** (`number`, default `0`): Opens with this many prints already on the face (up to maxPrints), at fixed spots. Turns the demo off.
- **colorScheme** (`'light' | 'dark' | 'system'`): Its colours: light, dark, or the reader’s system setting. Without it the key follows the page’s color-scheme, which is light unless the page sets one.
- **className** (`string`): A class for the stage, to size or place it.

## Accessibility

- A native button: it takes focus in order, shows a 2 px focus ring outside the key on keyboard focus, and Enter or Space press it, visibly, for as long as they are held.
- The key’s accessible name is its label. The slab, the gloss and the fingerprints are decorative and hidden from assistive technology.
- Disabled, it is a disabled button: skipped by the Tab key, greyed out, and inert to pointer and keys.
- The demo pointer is hidden from assistive technology, never runs under reduced motion, and stops at the first input. Under reduced motion the key still sinks and rises, only without the motion in between.

## Source

### tactile-button/tactile-button.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { PRINTS, PRINT_BOX } from './fingerprint';
import { useAutoplay } from './use-autoplay';
import styles from './tactile-button.module.css';

/*
 * Tactile button: a glossy violet key on a darker slab, that sinks when pressed and keeps the
 * fingerprint of every press.
 *
 * - The key is three layers in CSS: the slab (the key's side, seen below its face), the face
 *   (a vertical gradient), and a gloss over the face (inset light at its rims, top and bottom).
 *   Pressing slides the face down onto the slab, by the slab's whole depth, and narrows it by
 *   2 %; the drop shadow tightens and the gloss dims. Down in 70 ms, back up in 110 ms, by
 *   transform and opacity only.
 * - Every press, by pointer or key, leaves a fingerprint on the face where it landed (a key
 *   leaves one somewhere on the face): ridges drawn once as SVG paths (fingerprint.ts), turned
 *   and sized a little differently each time. A print is faint while the finger is on it and
 *   settles in as the key comes up. The newest `maxPrints` stay; older ones fade away.
 * - Space and Enter press it as a finger does, for as long as they are held.
 * - While on screen and untouched, a demo pointer taps the key here and there and smudges it, then
 *   wipes it clean and starts again. Any input of the reader's ends the demo.
 * - Light or dark: every colour is a light-dark() token, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

type Print = {
  id: number;
  /** Where it sits, in shares of the face's width and height. */
  x: number;
  y: number;
  /** Its turn in degrees, its size, and which of the drawn prints it is. */
  turn: number;
  size: number;
  shape: number;
  /** Faint while still pressed; leaving when pushed out by newer prints. */
  fresh: boolean;
  leaving: boolean;
};

type Stop = { x: number; y: number; hold: number };

/**
 * The demo's taps: where on the face (shares of its width and height) and how long the key is held
 * down, in ms. Between taps the pointer glides for GLIDE ms and rests REST ms.
 */
const TAPS: Stop[] = [
  { x: 0.16, y: 0.69, hold: 130 },
  { x: 0.85, y: 0.6, hold: 150 },
  { x: 0.19, y: 0.62, hold: 190 },
  { x: 0.47, y: 0.34, hold: 170 },
  { x: 0.1, y: 0.38, hold: 230 },
  { x: 0.14, y: 0.69, hold: 700 },
  { x: 0.45, y: 0.69, hold: 170 },
  { x: 0.85, y: 0.65, hold: 150 },
  { x: 0.38, y: 0.62, hold: 150 },
  { x: 0.33, y: 0.42, hold: 230 },
  { x: 0.12, y: 0.48, hold: 190 },
  { x: 0.67, y: 0.5, hold: 170 },
  { x: 0.35, y: 0.5, hold: 170 },
];
const GLIDE = 300;
const REST = 160;

let nextId = 1;

/** The prints a key opens with: the demo's first spots, each turned and sized its own way. */
function startingPrints(count: number, max: number): Print[] {
  const total = Math.max(0, Math.min(Math.floor(count) || 0, Math.max(1, max), TAPS.length));
  return TAPS.slice(0, total).map((tap, index) => ({
    id: nextId++,
    x: tap.x,
    y: tap.y,
    turn: ((index * 41) % 70) - 35,
    size: 0.9 + (index % 5) * 0.05,
    shape: index % PRINTS.length,
    fresh: false,
    leaving: false,
  }));
}

export function TactileButton({
  label = 'take action',
  onPress,
  disabled = false,
  fingerprints = true,
  maxPrints = 14,
  autoplay = true,
  defaultPressed = false,
  defaultPrints = 0,
  colorScheme,
  className = '',
}: {
  /** The words on the key. */
  label?: string;
  /** Called when the key is pressed (a click, a tap, Enter or Space). */
  onPress?: () => void;
  /** Greys the key out: it no longer sinks, takes prints or calls onPress. */
  disabled?: boolean;
  /** Each press leaves a fingerprint on the face. */
  fingerprints?: boolean;
  /** How many prints the face keeps; older ones fade away. */
  maxPrints?: number;
  /** While on screen and untouched, a demo pointer taps the key and smudges it. */
  autoplay?: boolean;
  /** Opens with the key held down, until it is pressed and let go. Turns the demo off. */
  defaultPressed?: boolean;
  /** Opens with this many prints already on the face, at fixed spots. Turns the demo off. */
  defaultPrints?: number;
  /** 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>(5000);
  const [pressed, setPressed] = useState(defaultPressed);
  const [prints, setPrints] = useState<Print[]>(() => startingPrints(defaultPrints, maxPrints));
  const button = useRef<HTMLButtonElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  /** The pointer or key holding the key down: a pointer id, or 'key'. */
  const holder = useRef<number | 'key' | null>(null);
  const timers = useRef(new Set<number>());

  const later = (fn: () => void, ms: number) => {
    const timer = window.setTimeout(() => {
      timers.current.delete(timer);
      fn();
    }, ms);
    timers.current.add(timer);
  };
  useEffect(() => {
    const all = timers.current;
    return () => all.forEach((timer) => window.clearTimeout(timer));
  }, []);

  /** Leaves a print at (x, y), in shares of the face. */
  const smudge = (x: number, y: number) => {
    if (!fingerprints) return;
    const print: Print = {
      id: nextId++,
      x,
      y,
      turn: Math.round((Math.random() - 0.5) * 70),
      size: 0.9 + Math.random() * 0.2,
      shape: Math.floor(Math.random() * PRINTS.length),
      fresh: true,
      leaving: false,
    };
    setPrints((now) => {
      const staying = now.filter((p) => !p.leaving);
      const over = staying.length + 1 - Math.max(1, maxPrints);
      const out = new Set(staying.slice(0, Math.max(0, over)).map((p) => p.id));
      if (out.size) later(() => setPrints((all) => all.filter((p) => !out.has(p.id))), 500);
      return [...now.map((p) => (out.has(p.id) ? { ...p, leaving: true } : p)), print];
    });
  };
  /** The key comes up: the newest prints settle in. */
  const settle = () => setPrints((now) => now.map((p) => (p.fresh ? { ...p, fresh: false } : p)));
  /** Wipes every print away. */
  const wipe = () => {
    setPrints((now) => now.map((p) => ({ ...p, leaving: true })));
    later(() => setPrints((now) => now.filter((p) => !p.leaving)), 500);
  };

  const press = () => setPressed(true);
  const release = () => {
    setPressed(false);
    settle();
  };

  /** Where a pointer is on the face, in shares of it (the face at rest: the key's top). */
  const placeOf = (event: { clientX: number; clientY: number }) => {
    const key = button.current;
    if (!key) return { x: 0.5, y: 0.5 };
    const rect = key.getBoundingClientRect();
    const depth = rect.height * DEPTH_SHARE;
    return {
      x: (event.clientX - rect.left) / rect.width,
      y: (event.clientY - rect.top) / (rect.height - depth),
    };
  };

  const onPointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
    if (disabled || event.button !== 0 || holder.current !== null) return;
    holder.current = event.pointerId;
    event.currentTarget.setPointerCapture?.(event.pointerId);
    const { x, y } = placeOf(event);
    press();
    smudge(x, y);
  };
  const onPointerUp = (event: ReactPointerEvent<HTMLButtonElement>) => {
    if (holder.current !== event.pointerId) return;
    holder.current = null;
    release();
  };
  const onKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
    if (disabled || event.repeat || (event.key !== ' ' && event.key !== 'Enter')) return;
    if (holder.current !== null) return;
    holder.current = 'key';
    press();
    // A key has no place: its print lands somewhere on the face, off the middle of the words.
    smudge(0.12 + Math.random() * 0.76, 0.3 + Math.random() * 0.4);
  };
  const onKeyUp = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
    if (holder.current !== 'key' || (event.key !== ' ' && event.key !== 'Enter')) return;
    holder.current = null;
    release();
  };
  const onBlur = () => {
    if (holder.current === 'key') {
      holder.current = null;
      release();
    }
  };

  // Disabling the key lets it come back up.
  useEffect(() => {
    if (!disabled) return;
    holder.current = null;
    setPressed(false);
  }, [disabled]);

  // The demo: the pointer glides to a spot on the key, presses it, holds, lets go; after the
  // last tap it rests, the key is wiped clean, and it all starts again.
  const demoPress = useEffectEvent((x: number, y: number) => {
    press();
    smudge(x, y);
  });
  const demoRelease = useEffectEvent(() => release());
  const demoWipe = useEffectEvent(() => wipe());
  const demo = autoplay && playing && !disabled && !defaultPressed && !defaultPrints;
  useEffect(() => {
    const stage = root.current;
    const hand = pointer.current;
    const key = button.current;
    if (!demo || !stage || !hand || !key) return;
    let alive = true;
    let flight: Animation | null = null;
    const pending = new Set<number>();
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        if (!alive) return reject(new Error('stopped'));
        const timer = window.setTimeout(() => {
          pending.delete(timer);
          if (alive) resolve();
          else reject(new Error('stopped'));
        }, ms);
        pending.add(timer);
      });
    /** The pointer's tip at (x, y) of the stage, in its own px. */
    const place = (x: number, y: number) => `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px)`;
    let at = { x: stage.offsetWidth * 0.62, y: stage.offsetHeight * 0.95 };
    const glide = async (to: { x: number; y: number }, ms: number) => {
      const from = at;
      at = to;
      flight?.cancel();
      hand.style.transform = place(to.x, to.y);
      flight = hand.animate(
        [{ transform: place(from.x, from.y) }, { transform: place(to.x, to.y) }],
        { duration: ms, easing: 'cubic-bezier(0.45, 0, 0.2, 1)' },
      );
      await wait(ms);
    };
    /** A spot on the face, in the stage's own px. */
    const spot = (x: number, y: number) => {
      const frame = stage.getBoundingClientRect();
      const scale = frame.width / stage.offsetWidth || 1;
      const rect = key.getBoundingClientRect();
      const face = rect.height * (1 - DEPTH_SHARE);
      return {
        x: (rect.left - frame.left + x * rect.width) / scale,
        y: (rect.top - frame.top + y * face) / scale,
      };
    };

    hand.style.transform = place(at.x, at.y);
    hand.style.opacity = '1';
    (async () => {
      for (;;) {
        for (const tap of TAPS) {
          await glide(spot(tap.x, tap.y), GLIDE);
          await wait(REST * 0.4);
          demoPress(tap.x, tap.y);
          await wait(tap.hold);
          demoRelease();
          await wait(REST);
        }
        await glide({ x: stage.offsetWidth * 0.66, y: stage.offsetHeight * 0.85 }, 500);
        await wait(1400);
        demoWipe();
        await wait(900);
      }
    })().catch(() => {});

    return () => {
      alive = false;
      pending.forEach((timer) => window.clearTimeout(timer));
      flight?.cancel();
      hand.style.opacity = '0';
      if (holder.current === null) demoRelease();
    };
  }, [demo, root]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        if (event.pointerType !== 'touch') take();
      }}
    >
      <button
        ref={button}
        type="button"
        className={styles.key}
        data-part="key"
        data-pressed={pressed}
        disabled={disabled}
        onPointerDown={onPointerDown}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
        onLostPointerCapture={onPointerUp}
        onKeyDown={onKeyDown}
        onKeyUp={onKeyUp}
        onBlur={onBlur}
        onClick={() => {
          if (!disabled) onPress?.();
        }}
      >
        <span className={styles.slab} data-part="slab" aria-hidden="true" />
        <span className={styles.face} data-part="face">
          <span className={styles.label} data-part="label">
            {label}
          </span>
          <span className={styles.prints} data-part="prints" aria-hidden="true">
            {prints.map((print) => (
              <svg
                key={print.id}
                className={styles.print}
                data-fresh={print.fresh}
                data-leaving={print.leaving}
                viewBox={`0 0 ${PRINT_BOX.width} ${PRINT_BOX.height}`}
                focusable="false"
                style={{
                  left: `${(print.x * 100).toFixed(2)}%`,
                  top: `${(print.y * 100).toFixed(2)}%`,
                  transform: `translate(-50%, -50%) rotate(${print.turn}deg) scale(${print.size.toFixed(2)})`,
                }}
              >
                <path d={PRINTS[print.shape]} />
              </svg>
            ))}
          </span>
          <span className={styles.gloss} data-part="gloss" aria-hidden="true" />
        </span>
      </button>
      <span ref={pointer} className={styles.pointer} data-part="demo-pointer" aria-hidden="true">
        <svg viewBox="0 0 24 24" focusable="false">
          {/* A pointing hand, its fingertip at the box's (8, 1). */}
          <path d="M6.6 2.6c0-1 .7-1.7 1.6-1.7s1.6.7 1.6 1.7v6.2c.3-.4.8-.6 1.3-.6.9 0 1.5.6 1.5 1.4v.4c.3-.4.8-.6 1.3-.6.9 0 1.5.6 1.5 1.4v.6c.3-.3.7-.5 1.2-.5.9 0 1.5.6 1.5 1.5v3.9c0 3.2-2.2 5.6-5.3 5.6h-1.6c-2 0-3.5-.9-4.6-2.6L3.4 15c-.5-.7-.3-1.6.4-2.1.7-.4 1.6-.3 2.1.4l.7 1z" />
          <path className={styles.knuckles} d="M9.8 11.4v3.2M12.6 12v2.6M15.4 12.7v1.9" />
        </svg>
      </span>
    </div>
  );
}

/**
 * The slab's depth as a share of the key's whole height: the face travels this far when
 * pressed. Must match --depth / (--face + --depth) in the stylesheet (10 / 118).
 */
const DEPTH_SHARE = 10 / 118;
```

### tactile-button/tactile-button.module.css

```css
/* Tactile button (tactile-button.tsx): a violet key on a pale lilac stage, or at night on a
   near-black one. Every size is in --v, one 40th of the label's type, so the whole key scales
   with its font-size: the face is 108 v tall with a 20 v radius, the slab under it 10 v deep,
   which is also how far the face travels. The type is 4.12 % of the stage's width (a container
   query), so the key keeps its share of the stage, never under 16 px type.

   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. The press moves the face by a
   transform and dims the gloss by opacity; only the slab's small shadow repaints. */

.root {
  /* The stage. */
  --stage: #f0eef4;
  --line: rgb(0 0 0 / 0.04);
  /* The face, top to bottom, and its words. */
  --face-1: #7d44ea;
  --face-2: #7638e7;
  --face-3: #7031e5;
  --face-4: #6826e0;
  --ink: #fff;
  --ink-shadow: rgb(52 8 150 / 0.35);
  /* The gloss: the rims of light at the face's edges, and the glow inside them. */
  --rim-top: rgb(204 176 255 / 0.9);
  --rim-bottom: rgb(196 150 255 / 0.95);
  --glow: rgb(172 132 255 / 0.4);
  --glow-side: rgb(165 125 255 / 0.4);
  /* The halo just above the face, and the shade its top rim casts when pressed in. */
  --halo: rgb(196 168 255 / 0.45);
  --shade: rgb(50 10 150 / 0.4);
  --press-dim: rgb(70 10 220 / 0.08);
  /* The slab: the key's side, its lavender lip, and the key's shadow, at rest and pressed. */
  --slab: #490eb4;
  --lip: rgb(190 156 250 / 0.75);
  --shadow-near: rgb(70 30 150 / 0.2);
  --shadow-far: rgb(70 30 150 / 0.16);
  --shadow-pressed: rgb(70 30 150 / 0.2);
  /* The fingerprints. */
  --print: #fff;
  /* The same on both schemes: the focus ring, and the demo's pointer. */
  --focus: #387bff;
  --pointer: #fff;
  --pointer-edge: #16151a;
  position: relative;
  container-type: inline-size;
  contain: size layout;
  display: grid;
  place-items: center;
  width: 100%;
  aspect-ratio: 16 / 9;
  min-height: 260px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  box-shadow: inset 0 0 0 1px var(--line);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#f0eef4, #121016);
    --line: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.06));
    --face-1: light-dark(#7d44ea, #773ee6);
    --face-2: light-dark(#7638e7, #7034e2);
    --face-3: light-dark(#7031e5, #6a2ddf);
    --face-4: light-dark(#6826e0, #6022d6);
    --rim-top: light-dark(rgb(204 176 255 / 0.9), rgb(196 166 255 / 0.8));
    --slab: light-dark(#490eb4, #4712ab);
    --lip: light-dark(rgb(190 156 250 / 0.75), rgb(176 138 255 / 0.55));
    --shadow-near: light-dark(rgb(70 30 150 / 0.2), rgb(0 0 0 / 0.6));
    --shadow-far: light-dark(rgb(70 30 150 / 0.16), rgb(40 10 100 / 0.35));
    --shadow-pressed: light-dark(rgb(70 30 150 / 0.2), rgb(0 0 0 / 0.55));
  }
}
/* 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;
}

/* The key: the face in flow, the slab under it, 10 v lower. */
.key {
  --v: calc(1em / 40);
  --depth: calc(var(--v) * 10);
  --radius: calc(var(--v) * 20);
  position: relative;
  display: inline-grid;
  box-sizing: content-box;
  margin: 0;
  padding: 0 0 var(--depth);
  border: 0;
  border-radius: var(--radius);
  background: none;
  color: var(--ink);
  font:
    600 clamp(16px, 4.12cqi, 44px) / 1 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Display',
    Inter,
    'Segoe UI',
    Arial,
    sans-serif;
  -webkit-font-smoothing: antialiased;
  cursor: pointer;
  touch-action: manipulation;
  user-select: none;
  -webkit-user-select: none;
}
.key:focus {
  outline: none;
}
.key:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 4px;
}
.key:disabled {
  cursor: not-allowed;
}

.slab {
  position: absolute;
  inset: var(--depth) 0 0;
  border-radius: var(--radius);
  background: var(--slab);
  box-shadow:
    0 calc(var(--v) * 1.5) 0 var(--lip),
    0 calc(var(--v) * 4) calc(var(--v) * 9) var(--shadow-near),
    0 calc(var(--v) * 14) calc(var(--v) * 28) var(--shadow-far);
  transition:
    box-shadow 110ms cubic-bezier(0.25, 0.6, 0.4, 1),
    transform 110ms cubic-bezier(0.25, 0.6, 0.4, 1);
}
.key[data-pressed='true'] .slab {
  transform: scaleX(0.982);
  box-shadow:
    0 calc(var(--v) * 1.5) 0 var(--lip),
    0 calc(var(--v) * 2) calc(var(--v) * 5) var(--shadow-pressed),
    0 calc(var(--v) * 4) calc(var(--v) * 10) var(--shadow-far);
  transition-duration: 80ms;
}

.face {
  position: relative;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  height: calc(var(--v) * 108);
  padding: calc(var(--v) * 3) calc(var(--v) * 58.5) 0;
  overflow: hidden;
  border-radius: var(--radius);
  background: linear-gradient(
    180deg,
    var(--face-1) 0%,
    var(--face-2) 25%,
    var(--face-3) 45%,
    var(--face-4) 85%
  );
  box-shadow: 0 calc(var(--v) * -1) calc(var(--v) * 2) var(--halo);
  transform-origin: 50% 100%;
  will-change: transform;
  transition: transform 110ms cubic-bezier(0.25, 0.6, 0.4, 1);
}
.key[data-pressed='true'] .face {
  transform: translateY(var(--depth)) scaleX(0.982);
  transition-duration: 80ms;
  transition-timing-function: cubic-bezier(0.3, 0.7, 0.4, 1);
}

/* The rims of light and the glow inside them, over the words and the prints. */
.gloss {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow:
    inset 0 calc(var(--v) * 2) 0 var(--rim-top),
    inset 0 calc(var(--v) * 16) calc(var(--v) * 14) calc(var(--v) * -6) var(--glow),
    inset 0 calc(var(--v) * -2) 0 var(--rim-bottom),
    inset 0 calc(var(--v) * -14) calc(var(--v) * 12) calc(var(--v) * -6) var(--glow),
    inset calc(var(--v) * 6) 0 calc(var(--v) * 6) calc(var(--v) * -4) var(--glow-side),
    inset calc(var(--v) * -6) 0 calc(var(--v) * 6) calc(var(--v) * -4) var(--glow-side);
  pointer-events: none;
  transition: opacity 110ms ease;
}
.key[data-pressed='true'] .gloss {
  opacity: 0.45;
  transition-duration: 80ms;
}
/* Pressed in, the slab's edge shades the top of the face. */
.face::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: var(--press-dim);
  box-shadow: inset 0 calc(var(--v) * 3) calc(var(--v) * 4) var(--shade);
  pointer-events: none;
  opacity: 0;
  transition: opacity 110ms ease;
}
.key[data-pressed='true'] .face::after {
  opacity: 1;
  transition-duration: 80ms;
}

.label {
  position: relative;
  margin-right: -0.045em;
  letter-spacing: 0.045em;
  white-space: nowrap;
  text-shadow: 0 calc(var(--v) * 1) calc(var(--v) * 2) var(--ink-shadow);
}

/* The fingerprints, over the words: ridges in thin white strokes on a haze, faded at the edge. */
.prints {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.print {
  position: absolute;
  width: calc(var(--v) * 100);
  height: calc(var(--v) * 130);
  overflow: visible;
  fill: none;
  stroke: var(--print);
  stroke-width: 0.9;
  stroke-linecap: round;
  background: radial-gradient(
    closest-side,
    rgb(255 255 255 / 0.36),
    rgb(255 255 255 / 0.15) 60%,
    transparent
  );
  mask-image: radial-gradient(closest-side, #000 45%, transparent 100%);
  opacity: 0.42;
  animation: print-in 60ms ease-out;
  transition: opacity 140ms ease;
}
.print[data-fresh='true'] {
  opacity: 0.22;
}
.print[data-leaving='true'] {
  opacity: 0;
  transition-duration: 450ms;
}
@keyframes print-in {
  from {
    opacity: 0;
  }
}

/* Greyed out: the key keeps its shape and loses its colour. */
.key:disabled .face,
.key:disabled .slab {
  filter: saturate(0.15) brightness(1.15);
}
.key:disabled .face {
  opacity: 0.75;
}

/* The demo's pointer: a pointing hand, its fingertip at (8, 1) of its 24-unit box. */
.pointer {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  width: 1em;
  height: 1em;
  margin: -0.04em 0 0 -0.34em;
  font-size: clamp(20px, 2.5cqi, 28px);
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
  filter: drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.3));
  will-change: transform;
}
.pointer svg {
  display: block;
  width: 100%;
  height: 100%;
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  stroke-width: 1.2;
  stroke-linejoin: round;
}
.pointer .knuckles {
  fill: none;
  stroke-width: 0.9;
  stroke-linecap: round;
}

@media (prefers-reduced-motion: reduce) {
  .face,
  .slab,
  .gloss,
  .face::after,
  .print {
    transition: none;
    animation: none;
  }
}
```

### tactile-button/fingerprint.ts

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

/*
 * The fingerprints a press leaves on the button, drawn once as SVG paths.
 *
 * A print is a family of ridges around a core: each ridge is a loop a little wider than the one
 * inside it, its radius nudged by a few slow waves so the loops wander like skin, the inner ones
 * drawn open at the bottom (a loop pattern, not a bullseye). Every ridge is broken into strokes
 * of random length with short gaps, as ridges end and fork on a real finger. The art fits a
 * 100 × 130 box; the stylesheet fades its edge with a radial mask. Three prints are drawn from
 * fixed seeds when the module loads, so the server and the browser draw the same ones.
 */

/** The box every print is drawn in. */
export const PRINT_BOX = { width: 100, height: 130 };

/** A small seeded random generator (mulberry32), so the prints are the same on every load. */
function random(seed: number) {
  let state = seed >>> 0;
  return () => {
    state = (state + 0x6d2b79f5) >>> 0;
    let t = state;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const round = (value: number) => Math.round(value * 10) / 10;

/** One print's ridges, as a single path. */
function drawPrint(seed: number): string {
  const rand = random(seed);
  const cx = 50;
  const cy = 62;
  /** Distance between ridges, in the box's units. */
  const gap = 2.25;
  const rings = 25;
  /** The ridges lean: a shear that turns their long sides diagonal. */
  const lean = (rand() - 0.5) * 0.5;
  // Each print has its own waves: their phases and strengths.
  const p1 = rand() * Math.PI * 2;
  const p2 = rand() * Math.PI * 2;
  const p3 = rand() * Math.PI * 2;
  const w1 = 0.05 + rand() * 0.03;
  const w2 = 0.025 + rand() * 0.02;
  let d = '';
  for (let ring = 1; ring <= rings; ring += 1) {
    const r = 1.6 + ring * gap;
    // The inner ridges open at the bottom, the outer ones close round them.
    const open = ring < 10 ? 1.1 - ring * 0.1 : 0;
    const start = Math.PI / 2 + open;
    const end = Math.PI / 2 + Math.PI * 2 - open;
    const steps = Math.max(24, Math.round(r * 3));
    // The core sits a little high, so the ridges crowd towards the tip of the finger.
    const lift = (rings - ring) * 0.45;
    let drawing = false;
    let left = 0;
    for (let i = 0; i <= steps; i += 1) {
      const angle = start + ((end - start) * i) / steps;
      const wave =
        1 +
        w1 * Math.sin(2 * angle + p1 + ring * 0.21) +
        w2 * Math.sin(3 * angle + p2 - ring * 0.13) +
        0.015 * Math.sin(7 * angle + p3);
      const y = cy - lift + Math.sin(angle) * r * wave;
      const x = cx + Math.cos(angle) * r * 0.82 * wave + (y - cy) * lean;
      if (left <= 0) {
        // A stroke ends here; a short gap, then a new one of random length.
        if (drawing && rand() < 0.75) {
          drawing = false;
          left = rand() < 0.7 ? 0.6 : 1 + rand() * 1.4;
          continue;
        }
        drawing = true;
        left = 2 + rand() * 7;
        d += `M${round(x)} ${round(y)}`;
        continue;
      }
      left -= 1;
      if (drawing) d += `L${round(x)} ${round(y)}`;
    }
  }
  return d;
}

/** The three prints, drawn once. */
export const PRINTS = [drawPrint(7), drawPrint(19), drawPrint(42)];
```

### tactile-button/use-autoplay.ts

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

'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 };
}
```