Buttons & Toggles

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.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

6
150
Active edited usageDefault props
<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.json

Or, 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

PropTypeDefaultDescription
childrenReact.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
depthnumber6Travel depth (px): how far the face floats above the base; fully pressed, it sits flush with the base
iconReact.ReactNode—Icon to the left of the label, sized 16/18/20px automatically per size
loadingbooleanfalseLoading: swaps the face content for a small spinner, sets aria-busy and ignores clicks, keeping the width
disabledbooleanfalseDisabled: 45% opacity, not-allowed cursor, and no movement at all
refReact.Ref<HTMLButtonElement>—Ref forwarded to the native <button>
classNamestring—Classes merged into the outer <button>

Every other native <button> attribute (onClick, type, aria-*, form, …) is passed through; type defaults to "button".

CSS variables

FieldTypeDefaultDescription
--wb-tactile-facecolorper variantFace colour (dark: neutral-700; light: neutral-100)
--wb-tactile-basecolorper variantBase colour (dark: neutral-900; light: neutral-300)
--wb-tactile-highlightcolorper variant1px inset highlight along the top of the face (dark: neutral-600; light: white)
--wb-tactile-contentcolorper variantLabel and icon colour (dark: neutral-100; light: neutral-900)
--wb-tactile-durationtime150msDuration of the face movement
--wb-tactile-easeeasingcubic-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-y variable, 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> with type defaulting to "button"; Space and Enter both activate it
  • On focus-visible a 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-busy and aria-disabled; the original label stays in the DOM as the accessible name and the spinner is hidden from assistive tech
  • Pass aria-label for icon-only keys; the icon wrapper is already aria-hidden
  • When the user has "reduce motion" enabled, the face no longer moves and instead darkens instantly while pressed (brightness 95%)