Components
Ripple
Ink that spreads from where a surface is pressed. Drop it inside anything pressable; it listens to its parent. A held press keeps the ink until release, and Enter or Space start it from the centre.
pnpm dlx shadcn@latest add @caliper/rippleWhen to use
Use it for
- Large pressable surfaces such as tiles, rows and cards, where a press needs a visible answer.
- Touch-first screens, where hover never happens.
Skip it when
- Buttons that already have a press state; two answers to one press is noise.
- Text links.
Decisions
Why it is built this way, so you know what to keep when you change your copy.
Behaviour
- Hold keeps the ink
- The ink grows to cover the surface and stays while held, then fades on release, so a long press is visibly a press.
- Reduced motion
- The spread is replaced by a brief tint over the whole surface.
Styling
- Current colour, faint
- It uses the text colour at 14%, so it suits any surface and never competes with the label.
Installation
pnpm dlx shadcn@latest add @caliper/ripple
Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
Usage
This is the code behind the preview above.
import { Check } from "lucide-react" import { useState, type ReactNode } from "react" import { Ripple } from "@/components/ui/ripple" function Panel({ title, context, className = "max-w-md", children }: { title: string; context: string; className?: string; children: ReactNode }) { return ( <section className={`grid w-full gap-5 rounded-overlay border border-border bg-background p-6 shadow-resting max-sm:p-4 ${className}`}> <div className="grid gap-1"> <p className="font-medium">{title}</p> <p className="text-sm text-muted-foreground">{context}</p> </div> {children} </section> ); } export function RippleDemo() { const [plan, setPlan] = useState("team"); const plans = [ { id: "solo", name: "Solo", price: "$12", note: "One person, every part" }, { id: "team", name: "Team", price: "$39", note: "Up to 10 seats, shared library" }, { id: "studio", name: "Studio", price: "$89", note: "Unlimited seats, priority help" }, ]; return ( <Panel title="Choose a plan" context="Press and hold a plan to see the ink stay." className="max-w-md"> <div role="radiogroup" aria-label="Plan" className="grid gap-2"> {plans.map((item) => ( <button key={item.id} type="button" role="radio" aria-checked={plan === item.id} onClick={() => setPlan(item.id)} className="relative flex items-center gap-3 overflow-hidden rounded-surface border border-border px-4 py-3 text-left outline-none focus-visible:outline-2 focus-visible:outline-ring aria-checked:border-foreground" > <span className="min-w-0 flex-1"> <span className="block font-medium">{item.name}</span> <span className="block text-sm text-muted-foreground">{item.note}</span> </span> <span className="text-sm tabular-nums">{item.price}/mo</span> <span className={`grid size-5 place-items-center rounded-full ${plan === item.id ? "bg-foreground text-background" : "border border-border"}`}>{plan === item.id && <Check aria-hidden className="size-3" />}</span> <Ripple /> </button> ))} </div> </Panel> ); }
API reference
Ripple
component
Also accepts the native props of its root element and a ref.
Accessibility
It is built on native elements and keeps aria-hidden in step with state.
Motion
It checks prefers-reduced-motion itself and settles immediately instead of travelling. Every state is still reachable.
Related
- ButtonA familiar action with immediate focus, a tactile press, and optional pending or label feedback that stays on the button itself.
- CardA titled container with media, status, meta and optional expandable details.
- ItemA generic row: media, title, description, meta and an action. It uses the data table's row spacing and its hover and selected fills.