Comparison Table
Plan comparison grid with a monthly/yearly switch that rolls every price, plan columns in a sticky header over scrolling feature rows, collapsible feature groups, check / dash / partial / text cells, a recommended column tinted top to bottom, and a plan switcher with one cross-fading column in narrow containers — the detailed companion to PricingTable.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/blocks/comparison-table
import { Check, ChevronDown, Minus } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type CSSProperties, type ReactNode, useCallback, useMemo, useState } from "react";
import { Button } from "@/components/motion/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
useCollapsible,
} from "@/components/motion/collapsible";
import { NumberTicker } from "@/components/motion/number-ticker";
import { SegmentedControl } from "@/components/motion/segmented-control";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ComparisonBilling = "monthly" | "yearly";
/** true = included, false = not included, "partial" = limited, anything else renders as text. */
export type ComparisonValue = boolean | "partial" | ReactNode;
export interface ComparisonPlan {
id: string;
name: string;
description?: string;
/** Price per month for each billing period (yearly = monthly equivalent). */
price: { monthly: number; yearly: number };
cta?: string;
/** Highlights the whole column. */
recommended?: boolean;
}
export interface ComparisonFeature {
id: string;
label: ReactNode;
/** Secondary line under the label. */
hint?: ReactNode;
/** Value per plan id. Missing ids read as not included. */
values: Record<string, ComparisonValue>;
}
export interface ComparisonGroup {
id: string;
label: string;
features: ComparisonFeature[];
}
export interface ComparisonTableProps {
plans: ComparisonPlan[];
groups: ComparisonGroup[];
billing?: ComparisonBilling;
defaultBilling?: ComparisonBilling;
onBillingChange?: (billing: ComparisonBilling) => void;
/** Groups open initially. Default: all. */
defaultOpenGroups?: string[];
/** Plan shown in the narrow (single-column) layout. */
mobilePlan?: string;
defaultMobilePlan?: string;
onMobilePlanChange?: (planId: string) => void;
onSelect?: (plan: ComparisonPlan, billing: ComparisonBilling) => void;
/** ISO 4217 code. Default "USD". */
currency?: string;
/** Default "en-US" so server and client agree. */
locale?: string;
/** Badge beside "Yearly". Default "−20%"; null hides it. */
yearlyBadge?: ReactNode;
/** Label on the recommended column. Default "Recommended". */
recommendedLabel?: ReactNode;
/** Classes for the scrolling body (set a max height here). Default `max-h-[22rem]`. */
bodyClassName?: string;
className?: string;
}
function useControllable<T>(
value: T | undefined,
defaultValue: T,
onChange: ((next: T) => void) | undefined,
) {
const [internal, setInternal] = useState(defaultValue);
const controlled = value !== undefined;
const current = controlled ? value : internal;
const set = useCallback(
(next: T) => {
if (!controlled) setInternal(next);
onChange?.(next);
},
[controlled, onChange],
);
return [current, set] as const;
}
/**
* Plan comparison grid: a monthly/yearly switch rolls every price, plan
* columns sit in a sticky header while feature rows scroll beneath, feature
* groups collapse with a height morph, cells read check / dash / partial or
* text, and the recommended plan's column is tinted top to bottom. In a
* narrow container the columns give way to a plan switcher with one value
* column that cross-fades between plans. Complements `PricingTable` (cards)
* as the detailed "compare all features" section.
*/
export function ComparisonTable({
plans,
groups,
billing: billingProp,
defaultBilling = "monthly",
onBillingChange,
defaultOpenGroups,
mobilePlan: mobilePlanProp,
defaultMobilePlan,
onMobilePlanChange,
onSelect,
currency = "USD",
locale = "en-US",
yearlyBadge = "−20%",
recommendedLabel = "Recommended",
bodyClassName,
className,
}: ComparisonTableProps) {
const [billing, setBilling] = useControllable(billingProp, defaultBilling, onBillingChange);
const [mobilePlan, setMobilePlan] = useControllable(
mobilePlanProp,
defaultMobilePlan ?? plans.find((p) => p.recommended)?.id ?? plans[0]?.id ?? "",
onMobilePlanChange,
);
const openSet = useMemo(
() => new Set(defaultOpenGroups ?? groups.map((g) => g.id)),
[defaultOpenGroups, groups],
);
const fractional = plans.some(
(p) => !Number.isInteger(p.price.monthly) || !Number.isInteger(p.price.yearly),
);
const money = useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: fractional ? 2 : 0,
maximumFractionDigits: fractional ? 2 : 0,
}),
[locale, currency, fractional],
);
const columns: CSSProperties = {
gridTemplateColumns: `minmax(8.5rem, 1.35fr) repeat(${plans.length}, minmax(0, 1fr))`,
};
const current = plans.find((p) => p.id === mobilePlan) ?? plans[0];
return (
<div className={cn("@container flex w-full flex-col gap-3", className)}>
<div className="flex justify-center">
<SegmentedControl
aria-label="Billing period"
size="sm"
value={billing}
onValueChange={(v) => setBilling(v as ComparisonBilling)}
options={[
{ value: "monthly", label: "Monthly" },
{
value: "yearly",
label: (
<span className="inline-flex items-center gap-1.5">
Yearly
{yearlyBadge ? (
<span className="rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none">
{yearlyBadge}
</span>
) : null}
</span>
),
},
]}
/>
</div>
<div className="overflow-hidden rounded-2xl border border-border bg-card text-card-foreground">
{/* Wide layout: all plan columns */}
<div
className={cn(
"hidden overflow-y-auto [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin] @xl:block",
bodyClassName ?? "max-h-[22rem]",
)}
>
<div className="sticky top-0 z-10 grid border-b border-border bg-card" style={columns}>
<div className="flex items-end p-3 text-xs text-muted-foreground">Compare plans</div>
{plans.map((plan) => (
<div
key={plan.id}
className={cn(
"relative flex flex-col items-start gap-1 p-3",
plan.recommended && "bg-primary/5",
)}
>
{plan.recommended ? (
<>
<span aria-hidden className="absolute inset-x-0 top-0 h-0.5 bg-primary" />
<span className="rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary-foreground">
{recommendedLabel}
</span>
</>
) : (
<span aria-hidden className="h-4" />
)}
<span className="text-sm font-semibold">{plan.name}</span>
<PlanPrice plan={plan} billing={billing} money={money} />
<Button
size="sm"
variant={plan.recommended ? "primary" : "secondary"}
onClick={() => onSelect?.(plan, billing)}
className="mt-1 h-7 w-full px-2"
>
{plan.cta ?? "Choose"}
</Button>
</div>
))}
</div>
{groups.map((group) => (
<Collapsible key={group.id} defaultOpen={openSet.has(group.id)}>
<GroupTrigger label={group.label} count={group.features.length} />
<CollapsibleContent>
{group.features.map((feature) => (
<div
key={feature.id}
className="grid border-b border-border/60 last:border-b-0"
style={columns}
>
<FeatureLabel feature={feature} />
{plans.map((plan) => (
<div
key={plan.id}
className={cn(
"flex items-center justify-center px-2 py-2.5",
plan.recommended && "bg-primary/5",
)}
>
<Cell value={feature.values[plan.id]} planName={plan.name} />
</div>
))}
</div>
))}
</CollapsibleContent>
</Collapsible>
))}
</div>
{/* Narrow layout: plan switcher + one value column */}
<div className="@xl:hidden">
<div className="border-b border-border p-3">
<SegmentedControl
aria-label="Plan"
size="sm"
fullWidth
value={current?.id}
onValueChange={setMobilePlan}
options={plans.map((p) => ({ value: p.id, label: p.name }))}
/>
{current ? (
<div className="mt-3 flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 text-sm font-semibold">
{current.name}
{current.recommended ? (
<span className="rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary-foreground">
{recommendedLabel}
</span>
) : null}
</p>
<PlanPrice plan={current} billing={billing} money={money} />
</div>
<Button
size="sm"
variant={current.recommended ? "primary" : "secondary"}
onClick={() => onSelect?.(current, billing)}
>
{current.cta ?? "Choose"}
</Button>
</div>
) : null}
</div>
<div
className={cn(
"overflow-y-auto [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]",
bodyClassName ?? "max-h-[22rem]",
)}
>
{groups.map((group) => (
<Collapsible key={group.id} defaultOpen={openSet.has(group.id)}>
<GroupTrigger label={group.label} count={group.features.length} />
<CollapsibleContent>
{group.features.map((feature) => (
<div
key={feature.id}
className="grid grid-cols-[1fr_6rem] border-b border-border/60 last:border-b-0"
>
<FeatureLabel feature={feature} />
<div
className={cn(
"flex items-center justify-center px-2 py-2.5",
current?.recommended && "bg-primary/5",
)}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={current?.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
className="flex"
>
<Cell
value={current ? feature.values[current.id] : undefined}
planName={current?.name ?? ""}
/>
</motion.span>
</AnimatePresence>
</div>
</div>
))}
</CollapsibleContent>
</Collapsible>
))}
</div>
</div>
</div>
</div>
);
}
function PlanPrice({
plan,
billing,
money,
}: {
plan: ComparisonPlan;
billing: ComparisonBilling;
money: Intl.NumberFormat;
}) {
const amount = plan.price[billing];
const free = plan.price.monthly === 0 && plan.price.yearly === 0;
return (
<span className="flex items-baseline gap-0.5">
<NumberTicker
value={Math.round(amount * 100)}
format={(cents) => money.format(cents / 100)}
duration={0.6}
stagger={0.03}
startOnView={false}
className="text-lg font-semibold tracking-tight"
/>
{free ? null : <span className="text-[11px] text-muted-foreground">/mo</span>}
</span>
);
}
function GroupTrigger({ label, count }: { label: string; count: number }) {
const { open } = useCollapsible("GroupTrigger");
const reduce = useReducedMotion();
return (
<CollapsibleTrigger className="flex w-full items-center gap-2 border-b border-border bg-muted/50 px-3 py-2 text-left text-xs font-semibold outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary">
<motion.span
animate={{ rotate: open ? 0 : -90 }}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className="grid place-items-center text-muted-foreground"
>
<ChevronDown aria-hidden className="size-3.5" />
</motion.span>
<span className="flex-1">{label}</span>
<span className="text-[10px] font-normal tabular-nums text-muted-foreground">
{count} {count === 1 ? "feature" : "features"}
</span>
</CollapsibleTrigger>
);
}
function FeatureLabel({ feature }: { feature: ComparisonFeature }) {
return (
<div className="min-w-0 px-3 py-2.5">
<p className="text-xs font-medium">{feature.label}</p>
{feature.hint ? <p className="mt-0.5 text-[11px] text-muted-foreground">{feature.hint}</p> : null}
</div>
);
}
function Cell({ value, planName }: { value: ComparisonValue | undefined; planName: string }) {
if (value === true) {
return (
<span className="grid size-5 place-items-center rounded-full bg-primary/15 text-primary">
<Check aria-hidden className="size-3" strokeWidth={3} />
<span className="sr-only">{planName}: included</span>
</span>
);
}
if (value === false || value === undefined || value === null) {
return (
<span className="grid size-5 place-items-center text-muted-foreground/60">
<Minus aria-hidden className="size-3.5" />
<span className="sr-only">{planName}: not included</span>
</span>
);
}
if (value === "partial") {
return (
<span className="grid size-5 place-items-center">
<span
aria-hidden
className="size-3 rounded-full border-2 border-primary bg-[linear-gradient(90deg,var(--primary)_50%,transparent_50%)]"
/>
<span className="sr-only">{planName}: partial</span>
</span>
);
}
return (
<span className="text-center text-xs text-foreground">
<span className="sr-only">{planName}: </span>
{value}
</span>
);
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import { motion, useReducedMotion, type Transition } from "motion/react";
import {
cloneElement,
createContext,
forwardRef,
isValidElement,
useCallback,
useContext,
useId,
useState,
type ButtonHTMLAttributes,
type HTMLAttributes,
type MouseEvent,
type ReactElement,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
// Collapsible is the unstyled disclosure primitive: one trigger, one panel.
// `Accordion` (accordion.tsx) is built from it; `BouncyAccordion` is the
// springy, single-open icon-row variant. Reach for this when a single region
// should show or hide in flow ("Show 3 more", an expandable activity row).
/** Entrance is a touch longer than the exit — exits should feel faster. */
const OPEN_TRANSITION: Transition = { duration: 0.28, ease: EASE_OUT };
const CLOSE_TRANSITION: Transition = { duration: 0.2, ease: EASE_OUT };
interface CollapsibleContextValue {
open: boolean;
setOpen: (open: boolean) => void;
disabled: boolean;
triggerId: string;
contentId: string;
}
const CollapsibleContext = createContext<CollapsibleContextValue | null>(null);
export function useCollapsible(component = "useCollapsible") {
const context = useContext(CollapsibleContext);
if (!context) {
throw new Error(`${component} must be used within <Collapsible>.`);
}
return context;
}
export interface CollapsibleProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
/** Controlled open state. */
open?: boolean;
/** Initial open state when uncontrolled. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
/** Override the generated trigger id (the content is labelled by it). */
triggerId?: string;
/** Override the generated content id (the trigger controls it). */
contentId?: string;
children?: ReactNode;
}
export const Collapsible = forwardRef<HTMLDivElement, CollapsibleProps>(
function Collapsible(
{
open: openProp,
defaultOpen = false,
onOpenChange,
disabled = false,
triggerId: triggerIdProp,
contentId: contentIdProp,
className,
children,
...rest
},
ref,
) {
const [internal, setInternal] = useState(defaultOpen);
const controlled = openProp !== undefined;
const open = controlled ? openProp : internal;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternal(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const baseId = useId();
const triggerId = triggerIdProp ?? `${baseId}-trigger`;
const contentId = contentIdProp ?? `${baseId}-content`;
return (
<CollapsibleContext.Provider
value={{ open, setOpen, disabled, triggerId, contentId }}
>
<div
ref={ref}
data-state={open ? "open" : "closed"}
data-disabled={disabled ? "" : undefined}
className={cn(className)}
{...rest}
>
{children}
</div>
</CollapsibleContext.Provider>
);
},
);
type ClonableChild = ReactElement<{
onClick?: (event: MouseEvent<HTMLElement>) => void;
[key: string]: unknown;
}>;
export interface CollapsibleTriggerProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Merge trigger behaviour onto the single child element instead of rendering a `<button>`. */
asChild?: boolean;
}
/** Toggles the nearest `Collapsible`. Unstyled `<button>` by default. */
export const CollapsibleTrigger = forwardRef<
HTMLButtonElement,
CollapsibleTriggerProps
>(function CollapsibleTrigger(
{ asChild, children, onClick, className, disabled: disabledProp, ...rest },
ref,
) {
const context = useCollapsible("CollapsibleTrigger");
const disabled = context.disabled || Boolean(disabledProp);
const shared = {
id: context.triggerId,
"aria-expanded": context.open,
"aria-controls": context.contentId,
"data-state": context.open ? "open" : "closed",
"data-disabled": disabled ? "" : undefined,
};
if (asChild && isValidElement(children)) {
const child = children as ClonableChild;
return cloneElement(child, {
...shared,
onClick: (event: MouseEvent<HTMLElement>) => {
child.props.onClick?.(event);
if (!event.defaultPrevented && !disabled) context.setOpen(!context.open);
},
});
}
return (
<button
ref={ref}
type="button"
disabled={disabled}
className={cn(className)}
{...rest}
{...shared}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) context.setOpen(!context.open);
}}
>
{children}
</button>
);
});
export interface CollapsibleContentProps extends HTMLAttributes<HTMLDivElement> {
/** Class for the inner wrapper that holds the children (padding goes here,
* never on the animated outer element, so the closed height is truly 0). */
innerClassName?: string;
/** Set to give the panel `role="region"` labelled by its trigger. */
region?: boolean;
children?: ReactNode;
}
/** The disclosed panel. Stays mounted (inert while closed) so `aria-controls` always resolves. */
export function CollapsibleContent({
className,
innerClassName,
region = false,
children,
style,
...rest
}: CollapsibleContentProps) {
const { open, triggerId, contentId } = useCollapsible("CollapsibleContent");
const reduce = useReducedMotion();
const fade = open ? OPEN_TRANSITION : CLOSE_TRANSITION;
return (
// Height is animated on purpose: disclosure is a layout job — the panel
// has to push the content below it down in flow, which transforms can't.
// Reduced motion snaps the height and keeps only the opacity fade.
<motion.div
{...(rest as Record<string, unknown>)}
id={contentId}
role={region ? "region" : undefined}
aria-labelledby={region ? triggerId : undefined}
data-state={open ? "open" : "closed"}
inert={!open}
initial={false}
animate={{ height: open ? "auto" : 0, opacity: open ? 1 : 0 }}
transition={
reduce ? { height: { duration: 0 }, opacity: { duration: 0.15 } } : fade
}
style={style}
className={cn("overflow-hidden", className)}
>
<div className={innerClassName}>{children}</div>
</motion.div>
);
}
"use client";
import { animate, motion, useInView, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface NumberTickerProps {
value: number;
/** Digits to pad to (left). */
pad?: number;
/** Per-digit roll duration in seconds. */
duration?: number;
/** Stagger between digits. */
stagger?: number;
/** Render only after the element enters the viewport. */
startOnView?: boolean;
prefix?: string;
suffix?: string;
/** Add a small blur during digit rolls. */
blur?: boolean;
className?: string;
digitClassName?: string;
/** Insert en-US group separators (commas); fixed so server and client match. Server-component safe. */
locale?: boolean;
/** Custom formatter. Client-only — server components must use `locale` instead. */
format?: (value: number) => string;
}
const DIGIT_HEIGHT_EM = 1.1;
const DIGITS = Array.from({ length: 10 }, (_, n) => n);
export function NumberTicker({
value,
pad,
duration = 0.9,
stagger = 0.04,
startOnView = true,
prefix,
suffix,
blur = false,
className,
digitClassName,
locale,
format,
}: NumberTickerProps) {
const containerRef = useRef<HTMLSpanElement>(null);
const inView = useInView(containerRef, { once: true, amount: 0.6 });
const [armed, setArmed] = useState(!startOnView);
useEffect(() => {
if (startOnView && inView) setArmed(true);
}, [startOnView, inView]);
const text = useMemo(() => {
const rounded = Math.round(value);
const formatted = format
? format(rounded)
: locale
? rounded.toLocaleString("en-US")
: rounded.toString();
return pad ? formatted.padStart(pad, "0") : formatted;
}, [value, pad, format, locale]);
const glyphs = useMemo(() => {
const chars = text.split("");
// Key by place value (position from the right): a changing digit keeps its
// identity and rolls to the new value instead of remounting and replaying
// from 0. Growing numbers add glyphs on the left without re-keying the
// ones, tens, hundreds already on screen.
return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));
}, [text]);
const readableText = `${prefix ?? ""}${text}${suffix ?? ""}`;
// Stagger is an entrance flourish. Once the reveal has played, value
// changes roll every digit immediately — a per-digit delay on live updates
// reads as lag.
const [entered, setEntered] = useState(false);
useEffect(() => {
if (!armed || entered) return;
const total = (duration + glyphs.length * stagger) * 1000;
const t = window.setTimeout(() => setEntered(true), total);
return () => window.clearTimeout(t);
}, [armed, entered, duration, stagger, glyphs.length]);
return (
<span
ref={containerRef}
className={cn("inline-flex items-center tabular-nums", className)}
>
<span className="sr-only">{readableText}</span>
<span aria-hidden="true" className="inline-flex items-center">
{prefix ? <span>{prefix}</span> : null}
{glyphs.map(({ char, id }, i) => {
const isDigit = /\d/.test(char);
if (!isDigit) {
return (
<span key={id} className="inline-block">
{char}
</span>
);
}
const digit = Number(char);
return (
<Digit
key={id}
digit={armed ? digit : 0}
delay={entered ? 0 : i * stagger}
duration={duration}
blur={blur}
className={digitClassName}
/>
);
})}
{suffix ? <span>{suffix}</span> : null}
</span>
</span>
);
}
function Digit({
digit,
delay,
duration,
blur,
className,
}: {
digit: number;
delay: number;
duration: number;
blur: boolean;
className?: string;
}) {
const reduce = useReducedMotion();
const columnRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {
return;
}
const node = columnRef.current;
const controls = animate(
node,
{ filter: ["blur(10px)", "blur(0px)"] },
{
duration: Math.min(duration * 0.75, 0.32),
delay,
ease: EASE_OUT,
},
);
return () => {
controls.stop();
node.style.filter = "blur(0px)";
};
}, [blur, delay, digit, duration, reduce]);
return (
<span
className={cn("relative inline-block overflow-hidden", className)}
style={{ height: `${DIGIT_HEIGHT_EM}em`, width: "1ch" }}
>
<motion.span
ref={columnRef}
initial={{ y: 0 }}
animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}
transition={
reduce
? { duration: 0 }
: { duration, delay, ease: EASE_OUT }
}
className="absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]"
>
{DIGITS.map((n) => (
<span
key={n}
className="flex h-[1.1em] items-center justify-center leading-none"
>
{n}
</span>
))}
</motion.span>
</span>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
useCallback,
useId,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
// SegmentedControl vs Tabs: `Tabs` (tabs.tsx, variants pill/segment/underline)
// switches *panels* with tablist/tab semantics. `SegmentedControl` is a form
// control: it picks one *value* with radiogroup/radio semantics, can post that
// value with a form via `name`, supports disabled options, icon-only options
// and a full-width layout, and its thumb is a raised card surface rather than
// a filled accent pill. Reach for Tabs when each option owns content below it.
export type SegmentedControlSize = "sm" | "md" | "lg";
export interface SegmentedControlOption {
value: string;
label?: ReactNode;
icon?: ReactNode;
disabled?: boolean;
/** Required when the option has only an icon. */
"aria-label"?: string;
}
const trackSize: Record<SegmentedControlSize, string> = {
sm: "h-8 p-0.5 text-xs",
md: "h-10 p-1 text-sm",
lg: "h-12 p-1 text-sm",
};
const itemSize: Record<SegmentedControlSize, string> = {
sm: "gap-1.5 px-2.5 [&_svg]:size-3.5",
md: "gap-2 px-3.5 [&_svg]:size-4",
lg: "gap-2 px-5 [&_svg]:size-[18px]",
};
function useControllable<T>(
value: T | undefined,
defaultValue: T,
onChange: ((next: T) => void) | undefined,
) {
const [internal, setInternal] = useState<T>(defaultValue);
const controlled = value !== undefined;
const current = controlled ? value : internal;
const set = useCallback(
(next: T) => {
if (!controlled) setInternal(next);
onChange?.(next);
},
[controlled, onChange],
);
return [current, set] as const;
}
/** Index of the next enabled option from `from` in `dir`, wrapping around. */
function nextEnabled(options: { disabled?: boolean }[], from: number, dir: 1 | -1) {
const n = options.length;
for (let step = 1; step <= n; step++) {
const i = (((from + dir * step) % n) + n) % n;
if (!options[i].disabled) return i;
}
return from;
}
export interface SegmentedControlProps {
options: SegmentedControlOption[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
size?: SegmentedControlSize;
/** Stretch to the container width with equal-width segments. */
fullWidth?: boolean;
disabled?: boolean;
/** When set, a hidden input posts the value with the surrounding form. */
name?: string;
"aria-label"?: string;
"aria-labelledby"?: string;
className?: string;
thumbClassName?: string;
}
export function SegmentedControl({
options,
value,
defaultValue,
onValueChange,
size = "md",
fullWidth = false,
disabled = false,
name,
className,
thumbClassName,
...aria
}: SegmentedControlProps) {
const [current, setValue] = useControllable(
value,
defaultValue ?? options.find((o) => !o.disabled)?.value ?? "",
onValueChange,
);
const reduce = useReducedMotion();
const layoutId = useId();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const items = options.map((o) => ({ ...o, disabled: disabled || o.disabled }));
const selectedIndex = items.findIndex((o) => o.value === current);
// Roving tab stop: the checked radio, or the first enabled one when none is.
const tabStop = selectedIndex >= 0 && !items[selectedIndex].disabled
? selectedIndex
: items.findIndex((o) => !o.disabled);
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next: number | null = null;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
next = nextEnabled(items, index, 1);
break;
case "ArrowLeft":
case "ArrowUp":
next = nextEnabled(items, index, -1);
break;
case "Home":
next = nextEnabled(items, -1, 1);
break;
case "End":
next = nextEnabled(items, items.length, -1);
break;
default:
return;
}
event.preventDefault();
if (next === null || items[next].disabled) return;
// Selection follows focus, as the WAI-ARIA radio group pattern prescribes.
refs.current[next]?.focus();
if (items[next].value !== current) setValue(items[next].value);
};
return (
// layoutRoot keeps the thumb's shared-layout projection local, so scrolled
// or fixed ancestors never leak offsets into its glide.
<motion.div
layoutRoot
role="radiogroup"
aria-disabled={disabled || undefined}
{...aria}
className={cn(
"relative isolate items-stretch rounded-xl border border-border bg-muted",
fullWidth ? "flex w-full" : "inline-flex",
trackSize[size],
disabled && "opacity-60",
className,
)}
>
{name ? <input type="hidden" name={name} value={current} /> : null}
{items.map((option, index) => {
const checked = option.value === current;
const iconOnly = !option.label;
return (
<motion.button
key={option.value}
ref={(el) => {
refs.current[index] = el;
}}
type="button"
role="radio"
aria-checked={checked}
aria-label={option["aria-label"]}
disabled={option.disabled}
tabIndex={index === tabStop ? 0 : -1}
data-state={checked ? "checked" : "unchecked"}
onClick={() => !checked && setValue(option.value)}
onKeyDown={(e) => onKeyDown(e, index)}
whileTap={reduce || option.disabled ? undefined : { scale: 0.96 }}
transition={SPRING_PRESS}
className={cn(
"relative inline-flex min-w-0 select-none items-center justify-center whitespace-nowrap rounded-[9px] font-medium outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-muted",
"disabled:cursor-not-allowed disabled:opacity-50",
fullWidth && "flex-1",
itemSize[size],
iconOnly && "aspect-square px-0",
checked
? "text-foreground"
: "text-muted-foreground enabled:hover:text-foreground",
)}
>
{checked ? (
<motion.span
layoutId={layoutId}
aria-hidden
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"absolute inset-0 -z-10 rounded-[9px] border border-border bg-card shadow-sm",
thumbClassName,
)}
/>
) : null}
{option.icon ? (
<span aria-hidden className="inline-flex shrink-0">
{option.icon}
</span>
) : null}
{option.label ? <span className="truncate">{option.label}</span> : null}
</motion.button>
);
})}
</motion.div>
);
}
/* ------------------------------------------------------------------------ */
/* ToggleGroup */
/* ------------------------------------------------------------------------ */
export interface ToggleGroupItem {
value: string;
label?: ReactNode;
icon?: ReactNode;
disabled?: boolean;
/** Required when the item has only an icon. */
"aria-label"?: string;
}
type ToggleGroupBase = {
items: ToggleGroupItem[];
size?: SegmentedControlSize;
disabled?: boolean;
"aria-label"?: string;
"aria-labelledby"?: string;
className?: string;
};
export type ToggleGroupSingleProps = ToggleGroupBase & {
type: "single";
/** The pressed item, or `""` when none is pressed. */
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Disallow un-pressing the active item, so one is always pressed. */
required?: boolean;
};
export type ToggleGroupMultipleProps = ToggleGroupBase & {
type: "multiple";
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
};
export type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;
const toggleSize: Record<SegmentedControlSize, string> = {
sm: "h-7 min-w-7 gap-1.5 px-2 text-xs [&_svg]:size-3.5",
md: "h-9 min-w-9 gap-2 px-2.5 text-sm [&_svg]:size-4",
lg: "h-11 min-w-11 gap-2 px-3.5 text-sm [&_svg]:size-[18px]",
};
export function ToggleGroup(props: ToggleGroupProps) {
const { items, size = "md", disabled = false, className } = props;
const reduce = useReducedMotion();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const [focusIndex, setFocusIndex] = useState<number | null>(null);
// Normalise both modes to a string[] internally.
const single = props.type === "single";
const [current, setCurrent] = useControllable<string[]>(
props.value === undefined ? undefined : single ? (props.value ? [props.value as string] : []) : (props.value as string[]),
single
? props.defaultValue
? [props.defaultValue as string]
: []
: ((props.defaultValue as string[] | undefined) ?? []),
(next) => {
if (props.type === "single") props.onValueChange?.(next[0] ?? "");
else props.onValueChange?.(next);
},
);
const list = items.map((i) => ({ ...i, disabled: disabled || i.disabled }));
const firstEnabled = list.findIndex((i) => !i.disabled);
const tabStop =
focusIndex !== null && !list[focusIndex]?.disabled ? focusIndex : firstEnabled;
const toggle = (itemValue: string) => {
const pressed = current.includes(itemValue);
if (props.type === "single") {
if (pressed && props.required) return;
setCurrent(pressed ? [] : [itemValue]);
} else {
setCurrent(pressed ? current.filter((v) => v !== itemValue) : [...current, itemValue]);
}
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = nextEnabled(list, index, 1);
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = nextEnabled(list, index, -1);
else if (event.key === "Home") next = nextEnabled(list, -1, 1);
else if (event.key === "End") next = nextEnabled(list, list.length, -1);
if (next === null) return;
event.preventDefault();
// Focus moves; pressing stays on Space/Enter (toggle buttons, not radios).
setFocusIndex(next);
refs.current[next]?.focus();
};
return (
<motion.div
role="group"
aria-label={props["aria-label"]}
aria-labelledby={props["aria-labelledby"]}
className={cn(
"inline-flex items-center gap-0.5 rounded-xl border border-border bg-card p-1",
disabled && "opacity-60",
className,
)}
>
{list.map((item, index) => {
const pressed = current.includes(item.value);
return (
<motion.button
key={item.value}
ref={(el) => {
refs.current[index] = el;
}}
type="button"
aria-pressed={pressed}
aria-label={item["aria-label"]}
disabled={item.disabled}
tabIndex={index === tabStop ? 0 : -1}
data-state={pressed ? "on" : "off"}
onClick={() => {
setFocusIndex(index);
toggle(item.value);
}}
onFocus={() => setFocusIndex(index)}
onKeyDown={(e) => onKeyDown(e, index)}
whileTap={reduce || item.disabled ? undefined : { scale: 0.88 }}
transition={SPRING_PRESS}
className={cn(
"relative isolate inline-flex select-none items-center justify-center rounded-lg font-medium outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card",
"disabled:cursor-not-allowed disabled:opacity-50",
toggleSize[size],
pressed
? "text-foreground"
: "text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground",
)}
>
<AnimatePresence initial={false}>
{pressed ? (
<motion.span
key="on"
aria-hidden
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.75, transition: { duration: 0.12 } }}
transition={SPRING_SWAP}
className="absolute inset-0 -z-10 rounded-lg bg-muted ring-1 ring-border-strong"
/>
) : null}
</AnimatePresence>
{item.icon ? (
<span aria-hidden className="inline-flex shrink-0">
{item.icon}
</span>
) : null}
{item.label ? <span>{item.label}</span> : null}
</motion.button>
);
})}
</motion.div>
);
}
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"relative inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
// The ripple layer clips itself, so on a touch screen the button can
// stop clipping and let its enlarged hit area overhang.
ripple && "overflow-hidden pointer-coarse:overflow-visible",
TOUCH_TARGET_CLASS,
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"relative inline-flex items-center justify-center font-medium select-none",
"transition-colors",
TOUCH_TARGET_CLASS,
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
plansComparisonPlan[]—groupsComparisonGroup[]—billing?"monthly" | "yearly"—defaultBilling?"monthly" | "yearly"monthlyonBillingChange?((billing: ComparisonBilling) => void)—defaultOpenGroups?string[]Groups open initially. Default: all.
—mobilePlan?stringPlan shown in the narrow (single-column) layout.
—defaultMobilePlan?string—onMobilePlanChange?((planId: string) => void)—onSelect?((plan: ComparisonPlan, billing: ComparisonBilling) => void)—currency?stringISO 4217 code. Default "USD".
USDlocale?stringDefault "en-US" so server and client agree.
en-USyearlyBadge?ReactNodeBadge beside "Yearly". Default "−20%"; null hides it.
−20%recommendedLabel?ReactNodeLabel on the recommended column. Default "Recommended".
RecommendedbodyClassName?stringClasses for the scrolling body (set a max height here). Default `max-h-[22rem]`.
—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated