Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Tactile button

New

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

tactile-button/tactile-button.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/tactile-button of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/tactile-button.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

confirm.tsx
import { TactileButton } from './tactile-button';export function Confirm({ onConfirm }: { onConfirm: () => void }) {  return <TactileButton label="take action" onPress={onConfirm} autoplay={false} />;}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.

  • 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.
PropTypeDefaultDescription
labelstring'take action'The words on the key.
onPress() => void—Called when the key is pressed: a click, a tap, Enter or Space.
disabledbooleanfalseGreys the key out: it no longer sinks, takes prints or calls onPress, and the demo is off.
fingerprintsbooleantrueEach press leaves a fingerprint on the face.
maxPrintsnumber14How many prints the face keeps; older ones fade away.
autoplaybooleantrueWhile on screen and untouched, a demo pointer presses the key here and there and smudges it.
defaultPressedbooleanfalseOpens with the key held down, until it is pressed and let go. Turns the demo off.
defaultPrintsnumber0Opens 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.
classNamestring—A class for the stage, to size or place it.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

tactile-button.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
tactile-button.module.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);
fingerprint.ts
// 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);
use-autoplay.ts
// 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(
  • 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.