/Inputs
Inputs • React
Bounce Checkbox
A checkbox whose fill springs out past the box with a little swell, draws its tick, then strikes the label through and dims it.
Installation
Requires motion for its icons. The shadcn CLI installs it for you; copying the code by hand means installing it yourself.
Terminal
npx shadcn@latest add @microkit/bounce-checkboxCode
1"use client";23import { useEffect, useLayoutEffect, useRef, useState } from "react";4import type { CSSProperties, PointerEvent, ReactNode } from "react";5import { animate, useMotionValue, useMotionValueEvent, useReducedMotion } from "motion/react";67export type StrikeSide = "left" | "center" | "right" | "none";89export type BounceCheckboxProps = {10label?: ReactNode;11checked?: boolean;12defaultChecked?: boolean;13onChange?: (checked: boolean) => void;14disabled?: boolean;15color?: string;16fillColor?: string;17checkColor?: string;18boxSize?: number;19boxRadius?: number;20fontSize?: number;21bounce?: number;22strikeLag?: number;23doneOpacity?: number;24strike?: StrikeSide;25ariaLabel?: string;26className?: string;27};2829const VISUAL_DURATION = 0.2;30const RULE_END = 0.84;31const SWELL = 0.35;32const TICK_PATH = "M5 14L8.5 17.5L19 6.5";33const ORIGIN: Record<StrikeSide, string> = {34left: "left center",35center: "center",36right: "right center",37none: "left center",38};3940const clamp01 = (value: number) => Math.min(1, Math.max(0, value));41const zetaOf = (bounce: number) =>42bounce <= 0 ? 1 : -Math.log(bounce) / Math.sqrt(Math.PI ** 2 + Math.log(bounce) ** 2);4344const readings = (t: number, doneOpacity: number, strikeLag: number) => {45const held = clamp01(t);46return {47fill: `scale(${Math.max(t, 0)})`,48box: `scale(${1 + SWELL * Math.max(0, t - 1)})`,49tick: 1 - held,50word: 1 - (1 - doneOpacity) * held,51rule: `scaleX(${clamp01((held - strikeLag) / (RULE_END - strikeLag))})`,52};53};5455export function BounceCheckbox({56label = "Write the changelog",57checked,58defaultChecked = false,59onChange,60disabled = false,61color = "light-dark(#14181e, #f2f4f6)",62fillColor = "light-dark(#14181e, #f2f4f6)",63checkColor = "light-dark(#ffffff, #0b0b0f)",64boxSize = 28,65boxRadius = 9,66fontSize = 18,67bounce = 0.2,68strikeLag = 0.12,69doneOpacity = 0.42,70strike = "left",71ariaLabel,72className = "",73}: BounceCheckboxProps) {74const controlled = checked !== undefined;75const [inner, setInner] = useState(defaultChecked);76const on = controlled ? checked : inner;77const reduce = useReducedMotion();7879const t = useMotionValue(on ? 1 : 0);80const viaPointer = useRef(false);81const instant = useRef(false);82const rowRef = useRef<HTMLButtonElement>(null);83const boxRef = useRef<HTMLSpanElement>(null);84const fillRef = useRef<HTMLSpanElement>(null);85const tickRef = useRef<SVGPathElement>(null);86const wordRef = useRef<HTMLSpanElement>(null);87const ruleRef = useRef<HTMLSpanElement>(null);88const cfg = useRef({ doneOpacity, strikeLag });8990useLayoutEffect(() => {91cfg.current = { doneOpacity, strikeLag };92});9394const write = (value: number) => {95const r = readings(value, cfg.current.doneOpacity, cfg.current.strikeLag);96if (fillRef.current) fillRef.current.style.transform = r.fill;97if (boxRef.current) boxRef.current.style.transform = r.box;98if (tickRef.current) tickRef.current.style.strokeDashoffset = String(r.tick);99if (wordRef.current) wordRef.current.style.opacity = String(r.word);100if (ruleRef.current) ruleRef.current.style.transform = r.rule;101};102useMotionValueEvent(t, "change", write);103useLayoutEffect(() => {104write(t.get());105});106107useEffect(() => {108const target = on ? 1 : 0;109if (reduce || instant.current) {110instant.current = false;111t.jump(target);112return undefined;113}114if (t.get() === target && t.getVelocity() === 0) return undefined;115const controls = animate(t, target, {116type: "spring",117visualDuration: VISUAL_DURATION,118bounce: 1 - zetaOf(bounce),119});120return () => controls.stop();121}, [on, reduce, bounce, t]);122123const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {124if (event.button !== 0 || disabled) return;125viaPointer.current = true;126if (!reduce && rowRef.current) rowRef.current.dataset.pressed = "";127};128const handlePointerUp = () => {129if (rowRef.current) delete rowRef.current.dataset.pressed;130};131const handlePointerCancel = () => {132viaPointer.current = false;133handlePointerUp();134};135const toggle = () => {136if (disabled) return;137instant.current = !viaPointer.current;138viaPointer.current = false;139const next = !on;140if (!controlled) setInner(next);141onChange?.(next);142};143144const initial = readings(on ? 1 : 0, doneOpacity, strikeLag);145const ring = boxSize >= 24 ? 2 : 1.5;146const gap = Math.min(16, Math.max(8, Math.round(boxSize * 0.43)));147const ruleHeight = Math.max(1.5, Math.round(fontSize / 6) / 2);148149const cssVars = {150"--bc-ink": color,151"--bc-fill": fillColor,152"--bc-check": checkColor,153"--bc-box": `${boxSize}px`,154"--bc-radius": `${boxRadius}px`,155"--bc-font": `${fontSize}px`,156"--bc-ring": `${ring}px`,157"--bc-gap": `${gap}px`,158"--bc-row": `${Math.max(44, boxSize + 16)}px`,159"--bc-rule": `${ruleHeight}px`,160"--bc-origin": ORIGIN[strike] || ORIGIN.left,161} as CSSProperties;162163return (164<button165ref={rowRef}166type="button"167role="checkbox"168aria-checked={on}169aria-label={ariaLabel}170disabled={disabled}171className={`group relative inline-flex min-h-[var(--bc-row)] cursor-pointer touch-manipulation select-none items-center gap-[var(--bc-gap)] border-0 bg-transparent p-0 text-left text-[length:var(--bc-font)] font-medium leading-[1.2] tracking-[-.01em] outline-none [color:var(--bc-ink)] [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] disabled:cursor-not-allowed disabled:opacity-50${className ? ` ${className}` : ""}`}172style={cssVars}173onPointerDown={handlePointerDown}174onPointerUp={handlePointerUp}175onPointerCancel={handlePointerCancel}176onPointerLeave={handlePointerCancel}177onClick={toggle}178>179<span className="h-[var(--bc-box)] w-[var(--bc-box)] flex-none rounded-[var(--bc-radius)] [transition:transform_160ms_cubic-bezier(.23,1,.32,1)] group-data-[pressed]:[transform:scale(.95)] group-focus-visible:outline-offset-[3px] group-focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--bc-ink)_45%,transparent)] motion-reduce:transition-none">180<span ref={boxRef} className="relative grid h-full w-full origin-center place-items-center overflow-hidden rounded-[inherit]" style={{ transform: initial.box }}>181<span182className="absolute inset-0 rounded-[inherit] opacity-[.28] [box-shadow:inset_0_0_0_var(--bc-ring)_var(--bc-ink)] [transition:opacity_120ms_ease] [@media(hover:hover)_and_(pointer:fine)]:group-enabled:group-hover:opacity-50"183aria-hidden="true"184/>185<span ref={fillRef} className="absolute inset-0 origin-center rounded-[inherit] [background:var(--bc-fill)]" style={{ transform: initial.fill }} />186<svg187className="relative h-[68%] w-[68%] overflow-visible fill-none [stroke:var(--bc-check)] [stroke-width:2.6] [stroke-linecap:round] [stroke-linejoin:round]"188viewBox="0 0 24 24"189aria-hidden="true"190>191<path ref={tickRef} d={TICK_PATH} pathLength={1} strokeDasharray={1} style={{ strokeDashoffset: initial.tick }} />192</svg>193</span>194</span>195<span className="relative inline-block">196<span ref={wordRef} className="inline-block" style={{ opacity: initial.word }}>197{label}198</span>199{strike !== "none" ? (200<span201ref={ruleRef}202className="pointer-events-none absolute inset-x-0 top-[46%] h-[var(--bc-rule)] rounded-[2px] bg-current [transform-origin:var(--bc-origin)]"203aria-hidden="true"204style={{ transform: initial.rule }}205/>206) : null}207</span>208</button>209);210}211212export function BounceCheckboxList() {213return (214<div className="flex flex-col items-start gap-1">215<BounceCheckbox label="Write the changelog" />216<BounceCheckbox label="Publish the release" />217</div>218);219}