Tactile Button
A two-layer physical key React component — the face floats above a solid base, dips 2px on hover and bottoms out on press, with depth from solid colour rather than blurred shadows.
A button with real key travel. Inside the <button> are two layers: a base offset down by the travel depth, and a face floating above it. On hover the face dips 2px; on press (or while holding Space/Enter on the keyboard) it sinks flush with the base. Depth is built entirely from solid colour — no blurred shadows, no spring — and both timing and colours are exposed as --wb-tactile-* variables. Three sizes, dark and light variants, a leading icon, and a loading state that keeps the width steady.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add tactile-button.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<TactileButton/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/tactile-button.en.jsonOr, once registries are configured in components.json, install it as @webberui/tactile-button.
Usage
import { Download } from "lucide-react";
import { TactileButton } from "@/components/ui/tactile-button";
<TactileButton onClick={() => console.log("Saved")}>Save</TactileButton>
<TactileButton variant="light" icon={<Download />} depth={8}>
Download report
</TactileButton>
<TactileButton size="lg" loading>
Paying
</TactileButton>Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | Key label or content |
size | "sm" | "md" | "lg" | "md" | Size: sm is 36px tall, md 44px, lg 52px; travel depth does not change with size |
variant | "dark" | "light" | "dark" | Colour scheme: dark face or light face; each switches to its own palette in dark mode |
depth | number | 6 | Travel depth (px): how far the face floats above the base; fully pressed, it sits flush with the base |
icon | React.ReactNode | — | Icon to the left of the label, sized 16/18/20px automatically per size |
loading | boolean | false | Loading: swaps the face content for a small spinner, sets aria-busy and ignores clicks, keeping the width |
disabled | boolean | false | Disabled: 45% opacity, not-allowed cursor, and no movement at all |
ref | React.Ref<HTMLButtonElement> | — | Ref forwarded to the native <button> |
className | string | — | Classes merged into the outer <button> |
Every other native <button> attribute (onClick, type, aria-*, form, …) is passed through; type defaults to "button".
CSS variables
| Field | Type | Default | Description |
|---|---|---|---|
--wb-tactile-face | color | per variant | Face colour (dark: neutral-700; light: neutral-100) |
--wb-tactile-base | color | per variant | Base colour (dark: neutral-900; light: neutral-300) |
--wb-tactile-highlight | color | per variant | 1px inset highlight along the top of the face (dark: neutral-600; light: white) |
--wb-tactile-content | color | per variant | Label and icon colour (dark: neutral-100; light: neutral-900) |
--wb-tactile-duration | time | 150ms | Duration of the face movement |
--wb-tactile-ease | easing | cubic-bezier(0.23,1,0.32,1) | Easing of the face movement |
How it works
- The button reserves the full travel with
padding-bottom, so its outer box never changes size as the face sinks and nothing around it shifts. - The hover dip only applies on
(hover: hover) and (pointer: fine)devices, so touch screens never get stuck half-pressed after a tap. - The three movement states (hover, press, keyboard hold) use mutually exclusive selectors that set a single
--tactile-yvariable, so CSS order does not matter. - Colours are always read as
var(--wb-tactile-*, variant default): set--wb-tactile-*on any ancestor to re-theme every key. In dark mode the dark variant inverts to a light face and the light variant turns into a dark face. - The loading state stacks content in one grid cell: the original label stays as an invisible placeholder with the spinner on top, so the width holds. Loading does not use native
disabled, so focus is not thrown away.
Accessibility
- Renders a native
<button>withtypedefaulting to"button"; Space and Enter both activate it - On
focus-visiblea 2px outline wraps the whole key (base included) and is unaffected by the face movement - Holding Space or Enter applies the same sink as a mouse press via
data-pressed, without relying on browser support for keyboard:active - While loading it sets
aria-busyandaria-disabled; the original label stays in the DOM as the accessible name and the spinner is hidden from assistive tech - Pass
aria-labelfor icon-only keys; theiconwrapper is alreadyaria-hidden - When the user has "reduce motion" enabled, the face no longer moves and instead darkens instantly while pressed (brightness 95%)
Equalizer Toggle
A sound toggle: the equalizer bars dance while it is on, and freeze in grayscale when it is off.
Switch
A spring-driven React switch whose thumb can be dragged to toggle, with optional crossfading icons inside the thumb, a pressed-state toggle-button variant and native form submission.