"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
export type KeycapProfile = "cherry" | "flat";
export type KeycapTheme = "auto" | "carbon" | "bone" | "accent";
export interface KeycapButtonProps extends Omit<React.ComponentPropsWithoutRef<"button">, "children"> {
/** The main legend: a character, a word or an inline SVG glyph. */
children?: React.ReactNode;
/** Smaller secondary legend (e.g. "shift" under ⇧, or "1" under "!"). */
subLegend?: React.ReactNode;
/**
* Accessible name. Defaults to the legend when it is a string; set it for glyphs ("⌘" → "Command").
* When a label is available the printed legends are hidden from assistive tech.
*/
label?: string;
/** Where the legend sits on the keycap. */
legendPosition?: "center" | "top-left" | "bottom-left";
/** Width in keyboard units (1 = square, 2.25 = left shift, 6.25 = spacebar). */
units?: number;
/** Size of one unit in px (also the key height). */
unitSize?: number;
/** `cherry`: tall skirt and a dished top. `flat`: low-profile chiclet. */
profile?: KeycapProfile;
/** Colourway. `auto` is bone on light backgrounds and carbon on dark ones. */
theme?: KeycapTheme;
/** Accent colour: the `accent` colourway, the latch LED, the clack flash and the focus ring. */
accent?: string;
/** How far the cap travels before it bottoms out, in px. */
travel?: number;
/** Flash a brief ring when the key bottoms out. */
clack?: boolean;
/** Spring used when the key returns up. */
spring?: Spring;
/** Controlled latch state. Passing it (or `toggle`) turns the key into a toggle with aria-pressed. */
pressed?: boolean;
/** Initial latch state in uncontrolled toggle mode. */
defaultPressed?: boolean;
/** Make an uncontrolled key latch (toggle) on each press. */
toggle?: boolean;
onPressedChange?: (pressed: boolean) => void;
}
/* --------------------------------- helpers --------------------------------- */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
// Colourways as CSS variables, so the layers below stay theme-agnostic.
const BONE =
"[--kc-top:#F6F3EC] [--kc-top2:#ECE7DC] [--kc-skirt:#DDD6C8] [--kc-skirt2:#C4BCAC] [--kc-legend:#26282B] [--kc-sub:#8A8478] [--kc-rim:rgba(255,255,255,0.85)] [--kc-shadow:rgba(60,48,30,0.28)]";
const CARBON =
"[--kc-top:#2D3035] [--kc-top2:#26292D] [--kc-skirt:#1F2225] [--kc-skirt2:#131517] [--kc-legend:#ECEAE4] [--kc-sub:#878C93] [--kc-rim:rgba(255,255,255,0.09)] [--kc-shadow:rgba(0,0,0,0.6)]";
const CARBON_DARK =
"dark:[--kc-top:#2D3035] dark:[--kc-top2:#26292D] dark:[--kc-skirt:#1F2225] dark:[--kc-skirt2:#131517] dark:[--kc-legend:#ECEAE4] dark:[--kc-sub:#878C93] dark:[--kc-rim:rgba(255,255,255,0.09)] dark:[--kc-shadow:rgba(0,0,0,0.6)]";
const ACCENT =
"[--kc-top:var(--kc-accent)] [--kc-top2:color-mix(in_oklab,var(--kc-accent)_90%,black)] [--kc-skirt:color-mix(in_oklab,var(--kc-accent)_74%,black)] [--kc-skirt2:color-mix(in_oklab,var(--kc-accent)_58%,black)] [--kc-legend:#0B0D0F] [--kc-sub:rgba(11,13,15,0.55)] [--kc-rim:rgba(255,255,255,0.4)] [--kc-shadow:color-mix(in_oklab,var(--kc-accent)_30%,rgba(0,0,0,0.5))]";
const THEMES: Record<KeycapTheme, string> = {
auto: `${BONE} ${CARBON_DARK}`,
bone: BONE,
carbon: CARBON,
accent: ACCENT,
};
const PROFILES = {
// Inset of the top surface from the skirt edges: [top, sides, bottom] in px.
cherry: { inset: [2, 5, 9], radius: 9, topRadius: 7 },
flat: { inset: [1.5, 2.5, 4], radius: 7, topRadius: 6 },
} as const;
const LEGEND_POSITION = {
center: "items-center justify-center text-center",
"top-left": "items-start justify-start text-left",
"bottom-left": "items-end justify-start text-left",
} as const;
/**
* A mechanical keyboard keycap with real depth: a skirt and an inset top surface that travel down
* together on a short spring and bottom out, with an optional "clack" flash. Works as a normal
* button (pointer, Enter, Space) or as a latching key with aria-pressed.
*/
export const KeycapButton = React.forwardRef<HTMLButtonElement, KeycapButtonProps>(function KeycapButton(
{
children,
subLegend,
label,
legendPosition = "center",
units = 1,
unitSize = 56,
profile = "cherry",
theme = "auto",
accent = "#FF7448",
travel = 4,
clack = true,
spring = { stiffness: 700, damping: 22, mass: 0.6 },
pressed: pressedProp,
defaultPressed = false,
toggle = false,
onPressedChange,
className = "",
style,
disabled,
onClick,
onPointerDown,
onPointerUp,
onPointerLeave,
onPointerCancel,
onKeyDown,
onKeyUp,
onBlur,
...props
},
ref,
) {
const reduceMotion = usePrefersReducedMotion();
const [down, setDown] = React.useState(false);
const [clacks, setClacks] = React.useState(0);
const [latchedInternal, setLatchedInternal] = React.useState(defaultPressed);
const isToggle = pressedProp !== undefined || toggle;
const latched = isToggle && (pressedProp ?? latchedInternal);
const shape = PROFILES[profile] ?? PROFILES.cherry;
const [insetTop, insetSide, insetBottom] = shape.inset;
const accessibleName = label ?? (typeof children === "string" ? children : undefined);
const press = () => {
if (disabled || down) return;
setDown(true);
if (clack && !reduceMotion) setClacks((n) => n + 1);
};
const release = () => setDown(false);
// Down: a near-critically-damped spring so the cap bottoms out without overshooting the plate.
// Up: the configurable spring, which may bounce a little on the way back.
const y = down ? travel : latched ? Math.round(travel * 0.55 * 100) / 100 : 0;
const transition = reduceMotion
? { duration: 0 }
: down
? { type: "spring" as const, stiffness: 1400, damping: 60, mass: 0.4 }
: { type: "spring" as const, ...spring };
return (
<button
ref={ref}
type="button"
disabled={disabled}
aria-label={accessibleName}
aria-pressed={isToggle ? latched : undefined}
data-down={down || undefined}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || !isToggle) return;
const next = !latched;
if (pressedProp === undefined) setLatchedInternal(next);
onPressedChange?.(next);
}}
onPointerDown={(event) => {
onPointerDown?.(event);
if (event.button === 0) press();
}}
onPointerUp={(event) => {
onPointerUp?.(event);
release();
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
release();
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
release();
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if ((event.key === "Enter" || event.key === " ") && !event.repeat) press();
}}
onKeyUp={(event) => {
onKeyUp?.(event);
if (event.key === "Enter" || event.key === " ") release();
}}
onBlur={(event) => {
onBlur?.(event);
release();
}}
className={`group/kc relative inline-block shrink-0 touch-manipulation select-none rounded-[10px] outline-hidden [-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-solid focus-visible:outline-[var(--kc-accent)] disabled:cursor-not-allowed disabled:opacity-45 ${THEMES[theme] ?? THEMES.auto} ${className}`}
style={{
width: Math.round(units * unitSize),
height: unitSize,
["--kc-accent" as string]: accent,
...style,
}}
{...props}
>
{/* Contact shadow on the plate: tightens as the cap travels down. */}
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-x-[3px] -bottom-[3px] top-[8px] bg-[var(--kc-shadow)] blur-[4px]"
style={{ borderRadius: shape.radius }}
initial={false}
animate={{ opacity: down ? 0.35 : latched ? 0.6 : 1, scaleX: down ? 0.96 : 1 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.12 }}
/>
{/* The keycap: skirt + top surface move together. */}
<motion.span
aria-hidden={accessibleName ? true : undefined}
className="absolute inset-0 block"
initial={false}
animate={{ y }}
transition={transition}
>
{/* Skirt: the sloped sides, darker towards the plate. */}
<span
className="absolute inset-0 block bg-[linear-gradient(180deg,var(--kc-skirt),var(--kc-skirt2))] shadow-[inset_0_1px_0_var(--kc-rim),inset_0_0_0_1px_rgba(0,0,0,0.12)]"
style={{ borderRadius: shape.radius }}
/>
{/* Top surface, inset more at the bottom so the front skirt reads as depth. */}
<span
className={`absolute flex overflow-hidden bg-[linear-gradient(180deg,var(--kc-top),var(--kc-top2))] px-[0.55em] py-[0.4em] text-[var(--kc-legend)] ${
profile === "cherry"
? "shadow-[inset_0_1px_0_var(--kc-rim),inset_0_-3px_6px_rgba(0,0,0,0.07),0_1px_2px_rgba(0,0,0,0.18)]"
: "shadow-[inset_0_1px_0_var(--kc-rim),0_1px_1px_rgba(0,0,0,0.14)]"
} ${LEGEND_POSITION[legendPosition] ?? LEGEND_POSITION.center}`}
style={{
top: insetTop,
left: insetSide,
right: insetSide,
bottom: insetBottom,
borderRadius: shape.topRadius,
fontSize: Math.round(unitSize * 0.27),
}}
>
{/* Cylindrical dish highlight (cherry only). */}
{profile === "cherry" && (
<span className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_85%_at_50%_0%,rgba(255,255,255,0.14),transparent_62%)]" />
)}
<span className="relative flex flex-col gap-[0.15em] leading-none">
<span className="font-medium tracking-tight [&_svg]:h-[1.05em] [&_svg]:w-auto">{children}</span>
{subLegend != null && (
<span className="font-mono text-[0.5em] uppercase tracking-[0.12em] text-[var(--kc-sub)]">{subLegend}</span>
)}
</span>
{/* Latch LED for toggle keys. */}
{isToggle && (
<span
className={`absolute right-[0.5em] top-[0.5em] size-[5px] rounded-full transition-[background-color,box-shadow] duration-200 ${
latched
? "bg-[var(--kc-accent)] shadow-[0_0_6px_1px_var(--kc-accent)]"
: "bg-[color-mix(in_oklab,var(--kc-legend)_18%,transparent)]"
}`}
/>
)}
{/* Bottom-out flash on the top surface. */}
<AnimatePresence>
{clacks > 0 && (
<motion.span
key={clacks}
className="pointer-events-none absolute inset-0 bg-white mix-blend-soft-light"
initial={{ opacity: 0 }}
animate={{ opacity: [0, 0.55, 0] }}
exit={{ opacity: 0 }}
transition={{ duration: 0.26, times: [0, 0.2, 1], delay: 0.03 }}
/>
)}
</AnimatePresence>
</span>
{/* "Clack": a thin accent ring that pops out as the switch bottoms out. */}
<AnimatePresence>
{clacks > 0 && (
<motion.span
key={clacks}
aria-hidden="true"
className="pointer-events-none absolute -inset-[3px] border border-[var(--kc-accent)]"
style={{ borderRadius: shape.radius + 3 }}
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: [0, 0.8, 0], scale: [0.97, 1.0, 1.06] }}
exit={{ opacity: 0 }}
transition={{ duration: 0.34, times: [0, 0.18, 1], delay: 0.03, ease: "easeOut" }}
/>
)}
</AnimatePresence>
</motion.span>
</button>
);
});