Buttons & Actionsv0.1.0
Spring Press Button
Scales down on press and springs back on release.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/spring-press-button.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/spring-press-button/spring-press-button.tsx"use client"; import { useState, type ComponentProps } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { duration, spring } from "@/lib/motion"; export interface SpringPressButtonProps extends Omit<ComponentProps<"button">, "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Visual style. @default "solid" */ variant?: "solid" | "primary" | "outline" | "ghost"; /** Button size. Small buttons keep a 40px hit area. @default "md" */ size?: "sm" | "md" | "lg"; /** Scale while pressed. Lower values feel squishier. @default 0.95 */ pressScale?: number; /** Spring used on release. "bouncy" overshoots slightly before settling. @default "bouncy" */ release?: "snappy" | "smooth" | "bouncy"; } const variantClasses = { solid: "bg-foreground text-background shadow-[inset_0_1px_0_rgb(255_255_255/0.14),0_1px_2px_rgb(0_0_0/0.14),0_4px_12px_-2px_rgb(0_0_0/0.12)] hover:bg-foreground/90", primary: "bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.2),0_1px_2px_rgb(0_0_0/0.12),0_4px_12px_-2px_color-mix(in_oklab,var(--primary)_40%,transparent)] hover:bg-primary/90", outline: "border border-border bg-background text-foreground shadow-xs hover:bg-accent", ghost: "text-foreground hover:bg-accent", } as const; const sizeClasses = { sm: "h-8 gap-1.5 rounded-md px-3 text-[13px] after:absolute after:-inset-1 after:content-['']", md: "h-10 gap-2 rounded-md px-4 text-sm", lg: "h-12 gap-2 rounded-lg px-6 text-[15px]", } as const; const PRESS_KEYS = new Set([" ", "Enter"]); export function SpringPressButton({ variant = "solid", size = "md", pressScale = 0.95, release = "bouncy", type = "button", disabled, className, children, onPointerDown, onPointerUp, onPointerLeave, onPointerCancel, onKeyDown, onKeyUp, onBlur, ...props }: SpringPressButtonProps) { const [pressed, setPressed] = useState(false); const reduceMotion = useReducedMotion(); const transition = reduceMotion ? { duration: duration.instant } : pressed ? spring.snappy : spring[release]; return ( <motion.button type={type} disabled={disabled} animate={reduceMotion ? { opacity: pressed ? 0.75 : 1 } : { scale: pressed ? pressScale : 1 }} transition={transition} onPointerDown={(event) => { onPointerDown?.(event); if (event.button === 0) setPressed(true); }} onPointerUp={(event) => { onPointerUp?.(event); setPressed(false); }} onPointerLeave={(event) => { onPointerLeave?.(event); setPressed(false); }} onPointerCancel={(event) => { onPointerCancel?.(event); setPressed(false); }} onKeyDown={(event) => { onKeyDown?.(event); if (PRESS_KEYS.has(event.key) && !event.repeat) setPressed(true); }} onKeyUp={(event) => { onKeyUp?.(event); if (PRESS_KEYS.has(event.key)) setPressed(false); }} onBlur={(event) => { onBlur?.(event); setPressed(false); }} className={cn( "relative inline-flex shrink-0 cursor-pointer items-center justify-center font-medium whitespace-nowrap outline-none select-none", "transition-[background-color,border-color,color,box-shadow] duration-150", "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0", variantClasses[variant], sizeClasses[size], className, )} {...props} > {children} </motion.button> ); }
Usage
import { SpringPressButton } from "@/components/ui/spring-press-button/spring-press-button";
export function Hero() {
return <SpringPressButton size="lg">Get started</SpringPressButton>;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "solid" | "primary" | "outline" | "ghost" | "solid" | Visual style. |
size | "sm" | "md" | "lg" | "md" | Button size. Small buttons keep a 40px hit area. |
pressScale | number | 0.95 | Scale while pressed. Lower values feel squishier. |
release | "snappy" | "smooth" | "bouncy" | "bouncy" | Spring used on release. "bouncy" overshoots slightly before settling. |
Also accepts every native <button> attribute, including ref and className.
- Press-in uses the snappy spring; release uses the spring named by `release`, so the button settles with a little life.
Accessibility
- Renders a native button, so Tab, Enter and Space work as expected.
- The press state follows Space and Enter as well as the pointer, so keyboard users get the same feedback.
- Releasing the pointer outside the button, or moving focus away, always releases the press.
- With reduced motion enabled, the scale is replaced by a brief dim.