Changelog
Release notes list with a version and date rail, New/Improved/Fixed badges, entries that expand to reveal their staggered change lines, a tag filter with counts that reflows the list with layout animation, and an email subscribe field driven by a StatefulButton promise.
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))
}
// 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;
"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;
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/blocks/changelog
import { ChevronDown, Mail } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type FormEvent, type ReactNode, useCallback, useId, useMemo, useRef, useState } from "react";
import { Badge, type BadgeVariant } from "@/components/motion/badge";
import { type ButtonState, StatefulButton } from "@/components/motion/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/motion/collapsible";
import { EmptyState } from "@/components/motion/empty-state";
import { Input } from "@/components/motion/input";
import { ToggleGroup, ToggleGroupItem } from "@/components/motion/toggle-group";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ChangelogTag = "new" | "improved" | "fixed";
export type ChangelogFilter = ChangelogTag | "all";
export interface ChangelogChange {
tag: ChangelogTag;
text: ReactNode;
}
export interface ChangelogEntry {
id: string;
version: string;
/** `YYYY-MM-DD`, formatted in UTC so server and client agree. */
date: string;
title: ReactNode;
summary?: ReactNode;
changes: ChangelogChange[];
}
export interface ChangelogProps {
entries: ChangelogEntry[];
filter?: ChangelogFilter;
defaultFilter?: ChangelogFilter;
onFilterChange?: (filter: ChangelogFilter) => void;
/** Ids of expanded entries. Default: the newest entry. */
expanded?: string[];
defaultExpanded?: string[];
onExpandedChange?: (expanded: string[]) => void;
/** Shows the subscribe field. Reject (or throw) to show an error state. */
onSubscribe?: (email: string) => Promise<unknown> | unknown;
subscribeLabel?: string;
locale?: string;
className?: string;
}
const TAGS: ChangelogTag[] = ["new", "improved", "fixed"];
const TAG_LABEL: Record<ChangelogTag, string> = {
new: "New",
improved: "Improved",
fixed: "Fixed",
};
const TAG_VARIANT: Record<ChangelogTag, BadgeVariant> = {
new: "success",
improved: "default",
fixed: "warning",
};
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
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;
}
export function Changelog({
entries,
filter: filterProp,
defaultFilter = "all",
onFilterChange,
expanded: expandedProp,
defaultExpanded,
onExpandedChange,
onSubscribe,
subscribeLabel = "Get release notes by email",
locale = "en-US",
className,
}: ChangelogProps) {
const reduce = useReducedMotion() ?? false;
const [filter, setFilter] = useControllable(filterProp, defaultFilter, onFilterChange);
const [expanded, setExpanded] = useControllable(
expandedProp,
defaultExpanded ?? (entries[0] ? [entries[0].id] : []),
onExpandedChange,
);
const dateFormat = useMemo(
() => new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }),
[locale],
);
const counts = useMemo(() => {
const c: Record<ChangelogFilter, number> = { all: entries.length, new: 0, improved: 0, fixed: 0 };
for (const e of entries) for (const t of TAGS) if (e.changes.some((ch) => ch.tag === t)) c[t] += 1;
return c;
}, [entries]);
const visible = useMemo(
() => (filter === "all" ? entries : entries.filter((e) => e.changes.some((c) => c.tag === filter))),
[entries, filter],
);
return (
<div className={cn("flex w-full flex-col gap-4 text-sm", className)}>
<div className="flex flex-wrap items-center justify-between gap-3">
<ToggleGroup
type="single"
required
variant="outline"
size="sm"
aria-label="Filter by change type"
value={filter}
onValueChange={(v) => v && setFilter(v as ChangelogFilter)}
>
{(["all", ...TAGS] as ChangelogFilter[]).map((t) => (
<ToggleGroupItem key={t} value={t}>
{t === "all" ? "All" : TAG_LABEL[t]}
<span className="font-mono text-[10px] tabular-nums text-muted-foreground">{counts[t]}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
<p aria-live="polite" className="sr-only">
{visible.length} release{visible.length === 1 ? "" : "s"} shown
</p>
</div>
{onSubscribe && <SubscribeForm label={subscribeLabel} onSubscribe={onSubscribe} />}
{visible.length === 0 ? (
<EmptyState size="sm" title="No releases match" description="Try another change type." />
) : (
<ol className="relative flex flex-col">
<AnimatePresence initial={false} mode="popLayout">
{visible.map((entry, i) => (
<EntryRow
key={entry.id}
entry={entry}
filter={filter}
last={i === visible.length - 1}
date={dateFormat.format(new Date(`${entry.date}T00:00:00Z`))}
open={expanded.includes(entry.id)}
onOpenChange={(next) =>
setExpanded(next ? [...expanded, entry.id] : expanded.filter((id) => id !== entry.id))
}
reduce={reduce}
/>
))}
</AnimatePresence>
</ol>
)}
</div>
);
}
function EntryRow({
entry,
filter,
last,
date,
open,
onOpenChange,
reduce,
}: {
entry: ChangelogEntry;
filter: ChangelogFilter;
last: boolean;
date: string;
open: boolean;
onOpenChange: (open: boolean) => void;
reduce: boolean;
}) {
const tags = TAGS.filter((t) => entry.changes.some((c) => c.tag === t));
const changes = filter === "all" ? entry.changes : entry.changes.filter((c) => c.tag === filter);
return (
<motion.li
layout={reduce ? false : "position"}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={SPRING_LAYOUT}
className="grid grid-cols-[5.5rem_1fr] gap-x-4 sm:grid-cols-[7rem_1fr]"
>
{/* Version + date rail */}
<div className="relative flex flex-col items-end pt-1 pr-4 text-right">
<span className="font-mono text-xs font-semibold text-foreground">{entry.version}</span>
<time dateTime={entry.date} className="text-[11px] text-muted-foreground">
{date}
</time>
{!last && (
<span aria-hidden="true" className="absolute -right-px top-4 bottom-0 w-px bg-border" />
)}
<span
aria-hidden="true"
className={cn(
"absolute -right-[5px] top-1.5 size-2.5 rounded-full border-2 border-background",
tags.includes("new") ? "bg-success" : "bg-foreground/30",
)}
/>
</div>
<div className="pb-6">
<Collapsible open={open} onOpenChange={onOpenChange} className="rounded-xl border border-border bg-card">
<div className="flex flex-col gap-1.5 px-4 pt-3">
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((t) => (
<Badge key={t} size="sm" variant={TAG_VARIANT[t]} tone="soft">
{TAG_LABEL[t]}
</Badge>
))}
</div>
<h3 className="text-[15px] font-semibold text-foreground">{entry.title}</h3>
{entry.summary && <p className="text-muted-foreground">{entry.summary}</p>}
</div>
<CollapsibleTrigger className="mt-1 flex w-full items-center gap-1.5 rounded-b-xl px-4 py-2.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
<motion.span
aria-hidden="true"
animate={{ rotate: open ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="flex"
>
<ChevronDown className="size-3.5" />
</motion.span>
{open ? "Hide" : "Show"} {changes.length} change{changes.length === 1 ? "" : "s"}
</CollapsibleTrigger>
<CollapsibleContent innerClassName="px-4 pb-4">
<ul className="flex flex-col gap-2 border-t border-border pt-3">
{changes.map((change, i) => (
<motion.li
// biome-ignore lint/suspicious/noArrayIndexKey: changes are static per entry
key={i}
initial={false}
animate={open ? { opacity: 1, x: 0 } : { opacity: 0, x: reduce ? 0 : -4 }}
transition={{ duration: 0.2, ease: EASE_OUT, delay: open ? i * 0.03 : 0 }}
className="flex items-start gap-2"
>
<Badge size="sm" variant={TAG_VARIANT[change.tag]} className="mt-px w-[4.75rem] shrink-0 justify-center">
{TAG_LABEL[change.tag]}
</Badge>
<span className="text-foreground/90">{change.text}</span>
</motion.li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
</div>
</motion.li>
);
}
function SubscribeForm({
label,
onSubscribe,
}: {
label: string;
onSubscribe: (email: string) => Promise<unknown> | unknown;
}) {
const id = useId();
const [email, setEmail] = useState("");
const [error, setError] = useState<string | undefined>();
const [state, setState] = useState<ButtonState>("idle");
const resetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (state === "loading") return;
if (!EMAIL.test(email.trim())) {
setError("Enter a valid email");
return;
}
setError(undefined);
setState("loading");
try {
await onSubscribe(email.trim());
setState("success");
setEmail("");
} catch (reason) {
setState("error");
setError(reason instanceof Error ? reason.message : "Couldn't subscribe");
}
clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => setState("idle"), 2400);
};
return (
<form
onSubmit={submit}
aria-labelledby={`${id}-label`}
className="flex flex-col gap-2 rounded-xl border border-border bg-muted/40 p-3 sm:flex-row sm:items-start"
>
<p id={`${id}-label`} className="sr-only">
{label}
</p>
<Input
type="email"
aria-label="Email"
placeholder={label}
value={email}
onChange={(v) => {
setEmail(v);
if (error) setError(undefined);
}}
error={error}
leftIcon={<Mail className="size-4" />}
className="min-w-0 flex-1"
/>
<StatefulButton
type="submit"
size="md"
state={state}
loadingText="Subscribing"
successText="Subscribed"
errorText="Try again"
className="shrink-0"
>
Subscribe
</StatefulButton>
</form>
);
}
"use client";
import { X } from "lucide-react";
import {
AnimatePresence,
motion,
PresenceContext,
useAnimate,
useReducedMotion,
type Variants,
} from "motion/react";
import {
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type BadgeVariant =
| "default"
| "secondary"
| "outline"
| "destructive"
| "success"
| "warning";
export type BadgeSize = "sm" | "md" | "lg";
/** Soft tinted surface or a filled one. Only the status variants differ. */
export type BadgeTone = "soft" | "solid";
const BASE: Record<"default" | "secondary" | "outline", string> = {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-foreground/[0.07] text-foreground",
outline: "border-border-strong bg-transparent text-foreground",
};
// Status tints mix the token toward --foreground/--background (as `alert`
// does), so the text keeps its contrast in both themes and every design style.
const SOFT: Record<"destructive" | "success" | "warning", string> = {
destructive:
"border-[color-mix(in_oklab,var(--destructive)_30%,transparent)] bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] text-[color-mix(in_oklab,var(--destructive)_78%,var(--foreground))]",
success:
"border-[color-mix(in_oklab,var(--success)_32%,transparent)] bg-[color-mix(in_oklab,var(--success)_11%,var(--background))] text-[color-mix(in_oklab,var(--success)_60%,var(--foreground))]",
warning:
"border-[color-mix(in_oklab,var(--warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--warning)_13%,var(--background))] text-[color-mix(in_oklab,var(--warning)_52%,var(--foreground))]",
};
// Filled status badges. Red reads best with light text in both themes;
// green and amber are light hues, so they always get dark text.
const SOLID: Record<"destructive" | "success" | "warning", string> = {
destructive:
"border-transparent bg-destructive text-background dark:text-foreground",
success:
"border-transparent bg-success text-[color-mix(in_oklab,var(--success)_22%,var(--foreground))] dark:text-[color-mix(in_oklab,var(--success)_22%,var(--background))]",
warning:
"border-transparent bg-warning text-[color-mix(in_oklab,var(--warning)_22%,var(--foreground))] dark:text-[color-mix(in_oklab,var(--warning)_22%,var(--background))]",
};
const SIZE: Record<BadgeSize, string> = {
sm: "h-5 gap-1 px-1.5 text-[11px] [&_svg]:size-3",
md: "h-6 gap-1.5 px-2 text-xs [&_svg]:size-3.5",
lg: "h-7 gap-1.5 px-2.5 text-[13px] [&_svg]:size-3.5",
};
// Count badges are circles at one digit and pills beyond.
const COUNT_SIZE: Record<BadgeSize, string> = {
sm: "h-4 min-w-4 px-1 text-[10px]",
md: "h-5 min-w-5 px-1.5 text-[11px]",
lg: "h-6 min-w-6 px-1.5 text-xs",
};
function variantClass(variant: BadgeVariant, tone: BadgeTone) {
if (variant === "default" || variant === "secondary" || variant === "outline") {
return BASE[variant];
}
return tone === "solid" ? SOLID[variant] : SOFT[variant];
}
export interface BadgeProps {
children?: ReactNode;
variant?: BadgeVariant;
/** Status variants only (destructive/success/warning). Default "soft". */
tone?: BadgeTone;
size?: BadgeSize;
/** Leading status dot in the text color; `"pulse"` adds a radiating ring. */
dot?: boolean | "pulse";
/** Leading icon (sized to the badge). Ignored when `dot` is set. */
icon?: ReactNode;
/**
* Makes the badge removable: an × button plays the exit, then calls this.
* Focus moves to the neighbouring chip's × when the removal came from it.
*/
onRemove?: () => void;
/** Accessible name of the × button. Default `Remove <text>`. */
removeLabel?: string;
/**
* Count mode: renders a compact number that rolls up or down as it changes
* and bumps when it rises. Hidden at 0 unless `showZero`.
*/
count?: number;
/** Counts above this read "max+". Default 99. */
max?: number;
showZero?: boolean;
/** Spoken label for count mode, e.g. `(n) => \`${n} unread\``. */
countLabel?: (count: number) => string;
className?: string;
}
/**
* General-purpose badge: six variants, three sizes, an optional (pulsing) dot
* or icon, removable chips with an exit animation, and a count mode with
* rolling digits capped at "99+". For animated status transitions
* (loading → success) use `AnimatedBadge` instead.
*/
export function Badge(props: BadgeProps) {
if (props.count !== undefined) return <CountBadge {...props} count={props.count} />;
return <LabelBadge {...props} />;
}
/* -------------------------------------------------------------------------- */
/* Label badge */
/* -------------------------------------------------------------------------- */
function LabelBadge({
children,
variant = "default",
tone = "soft",
size = "md",
dot = false,
icon,
onRemove,
removeLabel,
className,
}: BadgeProps) {
const reduce = useReducedMotion() ?? false;
// Inside an AnimatePresence the badge pops in when added; anywhere else it
// renders as-is so server HTML is visible before hydration.
const inPresence = useContext(PresenceContext) !== null;
const rootRef = useRef<HTMLSpanElement>(null);
const [leaving, setLeaving] = useState(false);
const refocus = useRef<HTMLElement | null>(null);
const removable = Boolean(onRemove);
const hidden = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 };
const startRemove = (fromKeyboardFocus: boolean) => {
if (leaving) return;
if (fromKeyboardFocus) refocus.current = neighbourRemoveButton(rootRef.current);
setLeaving(true);
};
const finishRemove = () => {
if (!leaving) return;
const target = refocus.current;
refocus.current = null;
onRemove?.();
if (target) requestAnimationFrame(() => target.focus({ preventScroll: true }));
};
const text = typeof children === "string" ? children : undefined;
return (
<motion.span
ref={rootRef}
data-badge=""
// Siblings glide into the gap a removed chip leaves (transform-only).
layout={removable && !reduce ? "position" : false}
initial={inPresence ? hidden : false}
animate={
leaving
? { ...hidden, pointerEvents: "none" }
: { opacity: 1, scale: 1, pointerEvents: "auto" }
}
// Already faded out by the × — leave at once so siblings close the gap.
exit={{
...hidden,
pointerEvents: "none",
transition: { duration: leaving ? 0 : 0.12, ease: EASE_OUT },
}}
transition={
leaving
? { duration: 0.14, ease: EASE_OUT }
: reduce
? { duration: 0.12 }
: { ...SPRING_PANEL, layout: SPRING_LAYOUT }
}
onAnimationComplete={finishRemove}
className={cn(
"inline-flex shrink-0 select-none items-center whitespace-nowrap rounded-full border font-medium leading-none",
SIZE[size],
variantClass(variant, tone),
removable && (size === "sm" ? "pr-0.5" : "pr-1"),
className,
)}
>
{dot ? (
<Dot pulse={dot === "pulse" && !reduce} />
) : icon ? (
<span aria-hidden="true" className="inline-flex shrink-0">
{icon}
</span>
) : null}
<span className="min-w-0 truncate">{children}</span>
{removable ? (
<button
type="button"
data-badge-remove=""
aria-label={removeLabel ?? (text ? `Remove ${text}` : "Remove")}
onClick={(event) => startRemove(event.detail === 0)}
onKeyDown={(event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault();
startRemove(true);
}
}}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full opacity-70 outline-none transition-[opacity,background-color] hover:bg-current/15 hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40",
size === "sm" ? "size-4" : "size-[18px]",
)}
>
<X aria-hidden="true" strokeWidth={2.4} className="size-3!" />
</button>
) : null}
</motion.span>
);
}
/** The × of the next chip in the row, else the previous one. */
function neighbourRemoveButton(node: HTMLElement | null) {
if (!node) return null;
const pick = (el: Element | null) =>
el?.querySelector<HTMLElement>("[data-badge-remove]") ?? null;
let next = node.nextElementSibling;
while (next && !pick(next)) next = next.nextElementSibling;
if (next) return pick(next);
let prev = node.previousElementSibling;
while (prev && !pick(prev)) prev = prev.previousElementSibling;
return prev ? pick(prev) : null;
}
// One pulse cycle of the dot's radiating ring, in seconds. Slow on purpose:
// an ambient "live" signal, not feedback, so it sits well outside UI timing.
const PULSE_DURATION = 1.6;
function Dot({ pulse }: { pulse: boolean }) {
return (
<span aria-hidden="true" className="relative inline-flex size-1.5 shrink-0">
{pulse ? (
<motion.span
className="absolute inset-0 rounded-full bg-current"
initial={{ scale: 1, opacity: 0.55 }}
animate={{ scale: 2.6, opacity: 0 }}
transition={{
duration: PULSE_DURATION,
ease: EASE_OUT,
repeat: Number.POSITIVE_INFINITY,
}}
/>
) : null}
<span className="relative size-1.5 rounded-full bg-current" />
</span>
);
}
/* -------------------------------------------------------------------------- */
/* Count badge */
/* -------------------------------------------------------------------------- */
function CountBadge({
count,
max = 99,
showZero = false,
countLabel,
variant = "destructive",
tone = "solid",
size = "md",
className,
}: BadgeProps & { count: number }) {
const reduce = useReducedMotion() ?? false;
const [scope, animate] = useAnimate<HTMLSpanElement>();
const value = Math.max(0, Math.round(count));
const visible = value > 0 || showZero;
const text = value > max ? `${max}+` : String(value);
// Direction is derived during render (not in an effect) so the very render
// that swaps the digits already rolls them the right way.
const [seen, setSeen] = useState(value);
const [direction, setDirection] = useState<1 | -1>(1);
if (value !== seen) {
setSeen(value);
setDirection(value > seen ? 1 : -1);
}
// Rising counts bump the pill (not on the pop-in from zero, which has its
// own entrance).
const previous = useRef(value);
useEffect(() => {
const prev = previous.current;
previous.current = value;
if (value > prev && prev > 0 && !reduce && scope.current) {
animate(scope.current, { scale: [1, 1.18, 1] }, { duration: 0.32, ease: EASE_OUT });
}
}, [value, reduce, animate, scope]);
return (
<AnimatePresence initial={false}>
{visible ? (
<motion.span
key="count"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}
animate={{ opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.1 } }
: { opacity: 0, scale: 0.4, transition: { duration: 0.14, ease: EASE_OUT } }
}
transition={reduce ? { duration: 0.12 } : SPRING_PANEL}
className={cn("inline-flex shrink-0", className)}
>
<span
ref={scope}
data-badge-count=""
className={cn(
"inline-flex items-center justify-center rounded-full border font-semibold leading-none tabular-nums",
COUNT_SIZE[size],
variantClass(variant, tone),
)}
>
<span className="sr-only">{countLabel ? countLabel(value) : text}</span>
<RollingCount text={text} direction={direction} reduce={reduce} />
</span>
</motion.span>
) : null}
</AnimatePresence>
);
}
const roll: Variants = {
enter: (dir: 1 | -1) => ({ y: dir > 0 ? "100%" : "-100%", opacity: 0 }),
center: { y: "0%", opacity: 1 },
exit: (dir: 1 | -1) => ({
y: dir > 0 ? "-100%" : "100%",
opacity: 0,
transition: { duration: 0.14, ease: EASE_OUT },
}),
};
const fade: Variants = {
enter: { opacity: 0 },
center: { opacity: 1 },
exit: { opacity: 0, transition: { duration: 0.1 } },
};
/**
* Direction-aware digit roll. `NumberTicker` spins each digit along a 0–9
* strip, so 9 → 10 would roll the ones digit backwards; here every changed
* glyph slides in from the direction of change instead.
*/
function RollingCount({
text,
direction,
reduce,
}: {
text: string;
direction: 1 | -1;
reduce: boolean;
}) {
const chars = text.split("");
// Keyed by place value from the right so a digit keeps its slot as the
// number grows on the left.
const glyphs = chars.map((char, i) => ({ char, place: chars.length - 1 - i }));
return (
<span aria-hidden="true" className="inline-flex">
{glyphs.map(({ char, place }) => (
<span
key={place}
className="relative inline-flex h-[1.2em] items-center justify-center overflow-hidden"
>
<span className="invisible">{char}</span>
<AnimatePresence initial={false} custom={direction}>
<motion.span
key={char}
custom={direction}
variants={reduce ? fade : roll}
initial="enter"
animate="center"
exit="exit"
transition={reduce ? { duration: 0.12 } : SPRING_SWAP}
className="absolute inset-0 flex items-center justify-center"
>
{char}
</motion.span>
</AnimatePresence>
</span>
))}
</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 { motion, useReducedMotion, type Variants } from "motion/react";
import type { ReactNode } from "react";
import { EASE_IN_OUT, EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type EmptyStateSize = "sm" | "md" | "lg";
export type EmptyStateIllustrationVariant = "documents" | "search";
/* -------------------------------------------------------------------------- */
/* Illustration */
/* -------------------------------------------------------------------------- */
export interface EmptyStateIllustrationProps {
variant?: EmptyStateIllustrationVariant;
/** Width in px; height follows the 5:4 viewBox. */
size?: number;
className?: string;
}
const draw = (delay: number, reduce: boolean) => ({
initial: reduce ? { pathLength: 1, opacity: 0 } : { pathLength: 0, opacity: 0 },
whileInView: { pathLength: 1, opacity: 1 },
viewport: { once: true },
transition: reduce
? { duration: 0.2 }
: {
pathLength: { duration: 0.5, ease: EASE_OUT, delay },
opacity: { duration: 0.1, delay },
},
});
// Idle float: translate only (no rotate/scale), so no SVG transform-origin is
// involved. Rotations in the art are static `transform` attributes on an
// inner <g> that motion never touches.
const float = (reduce: boolean, amplitude: number, duration: number, delay: number) =>
reduce
? {}
: {
animate: { y: [0, -amplitude, 0] },
transition: { duration, ease: EASE_IN_OUT, repeat: Infinity, delay },
};
/** Small default empty-state art: a floating document stack or a search lens. */
export function EmptyStateIllustration({
variant = "documents",
size = 120,
className,
}: EmptyStateIllustrationProps) {
const reduce = useReducedMotion() ?? false;
const lineClass = "stroke-border-strong";
return (
<svg
width={size}
height={(size * 4) / 5}
viewBox="0 0 120 96"
fill="none"
aria-hidden="true"
focusable="false"
className={cn("overflow-visible", className)}
>
<ellipse cx="60" cy="88" rx="30" ry="3.5" className="fill-muted" />
{variant === "documents" ? (
<>
<motion.g {...float(reduce, 2, 3.4, 0.4)}>
<g transform="rotate(-9 46 48)">
<rect x="24" y="20" width="44" height="56" rx="7" strokeWidth="1.5" className="fill-muted stroke-border-strong" />
</g>
</motion.g>
<motion.g {...float(reduce, 2.5, 3.8, 0.9)}>
<g transform="rotate(8 74 48)">
<rect x="52" y="20" width="44" height="56" rx="7" strokeWidth="1.5" className="fill-muted stroke-border-strong" />
</g>
</motion.g>
<motion.g {...float(reduce, 3.5, 3, 0)}>
<rect x="37" y="16" width="46" height="60" rx="7" strokeWidth="1.5" className="fill-card stroke-border-strong" />
<motion.path d="M46 30h20" strokeWidth="3" strokeLinecap="round" className="stroke-foreground/70" {...draw(0.25, reduce)} />
<motion.path d="M46 40h28" strokeWidth="3" strokeLinecap="round" className={lineClass} {...draw(0.35, reduce)} />
<motion.path d="M46 49h24" strokeWidth="3" strokeLinecap="round" className={lineClass} {...draw(0.45, reduce)} />
<motion.path d="M46 58h16" strokeWidth="3" strokeLinecap="round" className={lineClass} {...draw(0.55, reduce)} />
<motion.g
initial={{ opacity: 0, y: reduce ? 0 : 4 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.6 }}
>
<circle cx="84" cy="17" r="9" className="fill-primary" />
<path d="M84 13v8M80 17h8" strokeWidth="2" strokeLinecap="round" className="stroke-primary-foreground" />
</motion.g>
</motion.g>
</>
) : (
<>
<motion.g {...float(reduce, 1.5, 3.6, 0.5)}>
<rect x="22" y="22" width="64" height="50" rx="8" strokeWidth="1.5" className="fill-muted stroke-border-strong" />
<motion.path d="M32 36h30" strokeWidth="3" strokeLinecap="round" className={lineClass} {...draw(0.2, reduce)} />
<motion.path d="M32 46h22" strokeWidth="3" strokeLinecap="round" className={lineClass} {...draw(0.3, reduce)} />
<motion.path d="M32 56h26" strokeWidth="3" strokeLinecap="round" className={lineClass} {...draw(0.4, reduce)} />
</motion.g>
<motion.g {...float(reduce, 3.5, 3, 0)}>
<circle cx="76" cy="48" r="17" strokeWidth="1.5" className="fill-card stroke-border-strong" />
<motion.circle cx="76" cy="48" r="11" strokeWidth="3" className="stroke-foreground/70" {...draw(0.35, reduce)} />
<motion.path d="M84.5 56.5 96 68" strokeWidth="5" strokeLinecap="round" className="stroke-foreground/70" {...draw(0.6, reduce)} />
<motion.path d="M72.5 44.5l7 7M79.5 44.5l-7 7" strokeWidth="2" strokeLinecap="round" className="stroke-muted-foreground" {...draw(0.75, reduce)} />
</motion.g>
</>
)}
</svg>
);
}
/* -------------------------------------------------------------------------- */
/* EmptyState */
/* -------------------------------------------------------------------------- */
export interface EmptyStateProps {
title: ReactNode;
description?: ReactNode;
/**
* Icon or illustration slot. Defaults to `<EmptyStateIllustration />`;
* pass `null` for none.
*/
icon?: ReactNode;
/** Primary call to action (e.g. a library `Button`). */
action?: ReactNode;
/** Secondary call to action, rendered after the primary. */
secondaryAction?: ReactNode;
size?: EmptyStateSize;
/** Dashed border container, for empty regions inside a page. */
bordered?: boolean;
/** Heading element for the title. Default `h3`. */
titleAs?: "h2" | "h3" | "h4" | "p";
/** Extra content under the actions (links, hints). */
children?: ReactNode;
className?: string;
}
const SIZE: Record<
EmptyStateSize,
{ root: string; title: string; description: string; art: number }
> = {
sm: { root: "gap-3 px-4 py-6", title: "text-sm", description: "text-xs max-w-[32ch]", art: 88 },
md: { root: "gap-4 px-6 py-10", title: "text-base", description: "text-sm max-w-[38ch]", art: 120 },
lg: { root: "gap-5 px-8 py-14", title: "text-lg", description: "text-base max-w-[44ch]", art: 152 },
};
/**
* Empty-state block: illustration, title, description and actions that
* stagger in when scrolled into view (opacity + small rise; opacity only under
* reduced motion).
*/
export function EmptyState({
title,
description,
icon,
action,
secondaryAction,
size = "md",
bordered = false,
titleAs: Title = "h3",
children,
className,
}: EmptyStateProps) {
const reduce = useReducedMotion();
const s = SIZE[size];
const container: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.07, delayChildren: 0.05 } },
};
const item: Variants = {
hidden: { opacity: 0, y: reduce ? 0 : 8 },
show: { opacity: 1, y: 0, transition: { duration: 0.32, ease: EASE_OUT } },
};
const art = icon === undefined ? <EmptyStateIllustration size={s.art} /> : icon;
return (
<motion.div
variants={container}
initial="hidden"
whileInView="show"
viewport={{ once: true, amount: 0.4 }}
className={cn(
"flex w-full flex-col items-center text-center",
s.root,
bordered && "rounded-2xl border border-dashed border-border-strong",
className,
)}
>
{art != null ? (
<motion.div variants={item} className="flex items-center justify-center text-muted-foreground">
{art}
</motion.div>
) : null}
<div className="flex flex-col items-center gap-1.5">
<motion.div variants={item}>
<Title className={cn("font-semibold tracking-tight text-foreground", s.title)}>
{title}
</Title>
</motion.div>
{description != null ? (
<motion.p variants={item} className={cn("text-muted-foreground text-balance", s.description)}>
{description}
</motion.p>
) : null}
</div>
{action != null || secondaryAction != null ? (
<motion.div variants={item} className="flex flex-wrap items-center justify-center gap-2">
{action}
{secondaryAction}
</motion.div>
) : null}
{children != null ? <motion.div variants={item}>{children}</motion.div> : null}
</motion.div>
);
}
"use client";
import {
AnimatePresence,
animate,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
useEffect,
useId,
useRef,
useState,
type InputHTMLAttributes,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
export type InputClassNames = {
root?: string;
label?: string;
field?: string;
input?: string;
leftIcon?: string;
rightIcon?: string;
successIcon?: string;
errorMessage?: string;
};
export interface InputProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"value" | "defaultValue" | "onChange"
> {
label?: string;
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
/** Truthy error triggers a shake, red border and (if a string) a message. */
error?: string | boolean;
success?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
className?: string;
classNames?: InputClassNames;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
label,
value: valueProp,
defaultValue,
onChange,
onFocus,
onBlur,
error,
success,
leftIcon,
rightIcon,
className,
classNames,
disabled,
id: idProp,
type,
...rest
},
ref,
) {
const reactId = useId();
const id = idProp ?? reactId;
const reduce = useReducedMotion();
const controlled = valueProp !== undefined;
const [internal, setInternal] = useState(defaultValue ?? "");
const value = controlled ? (valueProp ?? "") : internal;
const [focused, setFocused] = useState(false);
const fieldRef = useRef<HTMLDivElement>(null);
const hasError = Boolean(error);
const errorMessage = typeof error === "string" ? error : null;
// Right edge shows the success check, otherwise the caller's right icon.
const rightSlot = success ? null : rightIcon;
// Shake the field when an error appears.
useEffect(() => {
if (!fieldRef.current || reduce || !hasError) return;
animate(
fieldRef.current,
{ x: [0, -6, 6, -4, 4, -2, 0] },
{ duration: 0.45 },
);
}, [hasError, reduce]);
const handleChange = (next: string) => {
if (!controlled) setInternal(next);
onChange?.(next);
};
return (
<div
className={cn("flex flex-col gap-1.5", className, classNames?.root)}
>
{label ? (
<label
htmlFor={id}
className={cn(
"px-1 text-sm font-medium text-foreground",
classNames?.label,
)}
>
{label}
</label>
) : null}
<div
ref={fieldRef}
data-state={
hasError
? "error"
: success
? "success"
: focused
? "focused"
: "idle"
}
className={cn(
"relative h-11 overflow-hidden rounded-full border transition-colors duration-200",
"border-border",
focused && !hasError && "border-foreground/40 ring-2 ring-ring/40",
hasError && "border-destructive ring-2 ring-destructive/25",
disabled && "opacity-60",
classNames?.field,
)}
>
{leftIcon ? (
<span
className={cn(
"pointer-events-none absolute left-3 top-1/2 flex -translate-y-1/2 items-center text-muted-foreground [&_svg]:h-4 [&_svg]:w-4",
classNames?.leftIcon,
)}
>
{leftIcon}
</span>
) : null}
<input
ref={ref}
id={id}
type={type}
value={value}
disabled={disabled}
aria-invalid={hasError || undefined}
aria-describedby={errorMessage ? `${id}-error` : undefined}
{...rest}
onChange={(e) => handleChange(e.target.value)}
onFocus={(event) => {
setFocused(true);
onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
onBlur?.(event);
}}
className={cn(
"peer h-full w-full bg-transparent text-base leading-6 text-foreground caret-foreground outline-none",
"placeholder:text-muted-foreground/60",
leftIcon ? "pl-10" : "pl-3.5",
rightSlot || success ? "pr-10" : "pr-3.5",
disabled && "cursor-not-allowed",
classNames?.input,
)}
/>
{success ? (
<motion.svg
viewBox="0 0 24 24"
fill="none"
className={cn(
"absolute right-3.5 top-1/2 h-5 w-5 -translate-y-1/2 text-(--color-success)",
classNames?.successIcon,
)}
>
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.35, ease: "easeOut" }}
/>
</motion.svg>
) : rightSlot ? (
<span
className={cn(
"absolute right-0 top-0 flex h-full items-center text-muted-foreground [&_button]:grid [&_button]:size-11 [&_button]:place-items-center [&_svg]:h-4 [&_svg]:w-4",
classNames?.rightIcon,
)}
>
{rightSlot}
</span>
) : null}
</div>
<AnimatePresence initial={false}>
{errorMessage ? (
<motion.p
id={`${id}-error`}
role="alert"
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: -4, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: -4, filter: "blur(4px)" }
}
transition={{ duration: 0.2 }}
className={cn(
"px-1 text-xs text-destructive",
classNames?.errorMessage,
)}
>
{errorMessage}
</motion.p>
) : null}
</AnimatePresence>
</div>
);
});
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
createContext,
forwardRef,
type KeyboardEvent,
type ReactNode,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
} from "react";
import { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Toggle / ToggleGroup vs SegmentedControl: `SegmentedControl`
// (segmented-control.tsx) is a radiogroup form control that always holds one
// value and ships a data-driven `ToggleGroup` for quick `items` arrays. This
// file is the composable, shadcn-shaped toolbar primitive: a standalone
// `Toggle`, and a `ToggleGroup` you fill with `ToggleGroupItem` children
// (icons, labels, tooltips, anything), with `default`/`outline` variants. In
// single mode one pressed surface glides between items; in multiple mode each
// item pops its own surface in and out.
export type ToggleVariant = "default" | "outline";
export type ToggleSize = "sm" | "md" | "lg";
const sizeClass: Record<ToggleSize, 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]",
};
const surfaceClass: Record<ToggleVariant, string> = {
default: "bg-muted ring-1 ring-border-strong",
outline: "bg-muted",
};
const itemBase =
"relative isolate inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium outline-none transition-colors duration-200 focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
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;
}
type ButtonBaseProps = Omit<HTMLMotionProps<"button">, "children" | "value" | "type">;
/** The pressed surface that pops in and out behind a standalone toggle or a multiple-mode item. */
function PopSurface({ show, variant }: { show: boolean; variant: ToggleVariant }) {
const reduce = useReducedMotion();
return (
<AnimatePresence initial={false}>
{show ? (
<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={cn("absolute inset-0 -z-10 rounded-[inherit]", surfaceClass[variant])}
/>
) : null}
</AnimatePresence>
);
}
/* ------------------------------------------------------------------------ */
/* Toggle */
/* ------------------------------------------------------------------------ */
export interface ToggleProps extends ButtonBaseProps {
pressed?: boolean;
defaultPressed?: boolean;
onPressedChange?: (pressed: boolean) => void;
variant?: ToggleVariant;
size?: ToggleSize;
children?: ReactNode;
}
/** A two-state button (`aria-pressed`) whose pressed surface springs in and out. */
export const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(
{
pressed,
defaultPressed = false,
onPressedChange,
variant = "default",
size = "md",
disabled,
className,
children,
onClick,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const [on, setOn] = useControllable(pressed, defaultPressed, onPressedChange);
return (
<motion.button
ref={ref}
type="button"
aria-pressed={on}
data-state={on ? "on" : "off"}
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) setOn(!on);
}}
className={cn(
itemBase,
TOUCH_TARGET_CLASS,
"rounded-lg focus-visible:ring-offset-1 focus-visible:ring-offset-background",
sizeClass[size],
variant === "outline" && "border border-border",
on
? "text-foreground"
: "text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground",
className,
)}
{...rest}
>
<PopSurface show={on} variant={variant} />
{children}
</motion.button>
);
});
/* ------------------------------------------------------------------------ */
/* ToggleGroup */
/* ------------------------------------------------------------------------ */
interface ToggleGroupContextValue {
type: "single" | "multiple";
pressed: string[];
toggle: (value: string) => void;
variant: ToggleVariant;
size: ToggleSize;
disabled: boolean;
layoutId: string;
tabStop: string | null;
setTabStop: (value: string) => void;
}
const ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null);
type ToggleGroupBase = Omit<
HTMLMotionProps<"div">,
"children" | "defaultValue" | "onChange" | "dir"
> & {
variant?: ToggleVariant;
size?: ToggleSize;
disabled?: boolean;
/** Arrow-key axis for roving focus. Default "horizontal" (both axes still work). */
orientation?: "horizontal" | "vertical";
children?: ReactNode;
};
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 ITEM_SELECTOR = "[data-toggle-group-item]";
function enabledItems(root: HTMLElement | null) {
if (!root) return [];
return Array.from(root.querySelectorAll<HTMLButtonElement>(ITEM_SELECTOR)).filter(
(el) => !el.disabled && el.closest("[data-toggle-group]") === root,
);
}
/**
* Toolbar-style group of `ToggleGroupItem`s. `type="single"` behaves like a
* set of mutually exclusive toggles (deselectable unless `required`) whose
* pressed surface glides between items; `type="multiple"` presses any subset.
* One tab stop, arrow keys / Home / End rove between items, Space/Enter press.
*/
export function ToggleGroup(props: ToggleGroupProps) {
const {
type,
value: _value,
defaultValue: _defaultValue,
onValueChange: _onValueChange,
variant = "default",
size = "md",
disabled = false,
orientation = "horizontal",
className,
children,
onKeyDown,
...rest
} = props;
// `required` only exists on the single variant; keep it off the DOM.
const { required: _required, ...domProps } = rest as typeof rest & { required?: boolean };
const single = props.type === "single";
const required = props.type === "single" && Boolean(props.required);
const [pressed, setPressed] = useControllable<string[]>(
props.value === undefined
? undefined
: props.type === "single"
? props.value
? [props.value]
: []
: props.value,
props.type === "single"
? props.defaultValue
? [props.defaultValue]
: []
: (props.defaultValue ?? []),
(next) => {
if (props.type === "single") props.onValueChange?.(next[0] ?? "");
else props.onValueChange?.(next);
},
);
const layoutId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [tabStop, setTabStop] = useState<string | null>(pressed[0] ?? null);
// Keep exactly one reachable tab stop: if the remembered item vanished or
// got disabled, fall back to the first pressed item, then the first enabled.
useEffect(() => {
const items = enabledItems(rootRef.current);
if (items.some((el) => el.dataset.value === tabStop)) return;
const fallback =
items.find((el) => pressed.includes(el.dataset.value ?? "")) ?? items[0];
const next = fallback?.dataset.value ?? null;
if (next !== tabStop) setTabStop(next);
});
const toggle = (itemValue: string) => {
const isOn = pressed.includes(itemValue);
if (single) {
if (isOn && required) return;
setPressed(isOn ? [] : [itemValue]);
} else {
setPressed(isOn ? pressed.filter((v) => v !== itemValue) : [...pressed, itemValue]);
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const items = enabledItems(rootRef.current);
const index = items.indexOf(document.activeElement as HTMLButtonElement);
if (index < 0) return;
const forward = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
const backward = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
let next: number;
if (event.key === forward || event.key === "ArrowDown" || event.key === "ArrowRight")
next = (index + 1) % items.length;
else if (event.key === backward || event.key === "ArrowUp" || event.key === "ArrowLeft")
next = (index - 1 + items.length) % items.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = items.length - 1;
else return;
event.preventDefault();
// Focus moves; pressing stays on Space/Enter — these are toggles, not radios.
items[next].focus();
};
return (
<ToggleGroupContext.Provider
value={{
type,
pressed,
toggle,
variant,
size,
disabled,
layoutId,
tabStop,
setTabStop,
}}
>
{/* layoutRoot keeps the gliding surface's projection local to the group,
so scrolled or transformed ancestors never skew the glide. */}
<motion.div
ref={rootRef}
layoutRoot
role="group"
data-toggle-group=""
data-orientation={orientation}
aria-disabled={disabled || undefined}
onKeyDown={handleKeyDown}
className={cn(
"inline-flex w-fit items-center",
orientation === "vertical" && "flex-col items-stretch",
variant === "default"
? "gap-0.5 rounded-xl border border-border bg-card p-1"
: "rounded-lg",
disabled && "opacity-60",
className,
)}
{...domProps}
>
{children}
</motion.div>
</ToggleGroupContext.Provider>
);
}
export interface ToggleGroupItemProps extends ButtonBaseProps {
value: string;
children?: ReactNode;
}
export const ToggleGroupItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
function ToggleGroupItem(
{ value, disabled: itemDisabled, className, children, onClick, onFocus, ...rest },
ref,
) {
const ctx = useContext(ToggleGroupContext);
if (!ctx) throw new Error("ToggleGroupItem must be used inside <ToggleGroup>.");
const reduce = useReducedMotion();
const disabled = ctx.disabled || Boolean(itemDisabled);
const on = ctx.pressed.includes(value);
const outline = ctx.variant === "outline";
return (
<motion.button
ref={ref}
type="button"
data-toggle-group-item=""
data-value={value}
data-state={on ? "on" : "off"}
aria-pressed={on}
disabled={disabled}
tabIndex={ctx.tabStop === value ? 0 : -1}
whileTap={reduce || disabled ? undefined : { scale: 0.88 }}
transition={SPRING_PRESS}
onFocus={(event) => {
ctx.setTabStop(value);
onFocus?.(event);
}}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
ctx.setTabStop(value);
ctx.toggle(value);
}}
className={cn(
itemBase,
TOUCH_TARGET_CLASS,
sizeClass[ctx.size],
outline
? // Joined segments: neighbours overlap their shared border.
"rounded-none border border-border bg-card -ml-px first:ml-0 first:rounded-l-lg last:rounded-r-lg in-data-[orientation=vertical]:-mt-px in-data-[orientation=vertical]:ml-0 in-data-[orientation=vertical]:first:mt-0 in-data-[orientation=vertical]:first:rounded-l-none in-data-[orientation=vertical]:first:rounded-t-lg in-data-[orientation=vertical]:last:rounded-r-none in-data-[orientation=vertical]:last:rounded-b-lg"
: "rounded-lg focus-visible:ring-offset-1 focus-visible:ring-offset-card",
on
? "text-foreground"
: "text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground",
className,
)}
{...rest}
>
{ctx.type === "single" ? (
on ? (
<motion.span
layoutId={ctx.layoutId}
aria-hidden
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"absolute inset-0 -z-10 rounded-[inherit]",
surfaceClass[ctx.variant],
)}
/>
) : null
) : (
<PopSurface show={on} variant={ctx.variant} />
)}
{children}
</motion.button>
);
},
);
"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>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import {
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
type CSSProperties,
type HTMLAttributes,
type PointerEvent as ReactPointerEvent,
type ReactNode,
type Ref,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
// ScrollArea keeps the browser's own scrolling (wheel, touch momentum,
// keyboard, scroll anchoring, find-in-page) and only replaces the *painted*
// scrollbar: the native bar is hidden and an overlay thumb is drawn on top.
// The thumb is a visual affordance (aria-hidden); keyboard and assistive tech
// scroll the focusable viewport itself.
export type ScrollAreaOrientation = "vertical" | "horizontal" | "both";
/** `hover`: visible while hovered or scrolling. `scroll`: only while scrolling. `always`: whenever content overflows. */
export type ScrollAreaType = "hover" | "scroll" | "always";
export interface ScrollAreaProps extends Omit<HTMLAttributes<HTMLDivElement>, "dir"> {
children?: ReactNode;
orientation?: ScrollAreaOrientation;
type?: ScrollAreaType;
/** Milliseconds of idle before the thumb fades out. */
scrollHideDelay?: number;
/** Fade the content out at edges that have more content beyond them. */
fadeEdges?: boolean;
/** Maximum fade length in px; it grows with the distance from the edge. */
fadeSize?: number;
/** Class for the scrolling viewport (padding usually belongs here). */
viewportClassName?: string;
/** Ref to the native scroll container, for `scrollTo` or virtualizers. */
viewportRef?: Ref<HTMLElement | null>;
/** Accessible name for the focusable scroll region. */
"aria-label"?: string;
}
const MIN_THUMB = 24;
const TRACK_INSET = 2;
/** Fraction of the visible size a track click pages by (keeps a little context). */
const PAGE_FACTOR = 0.9;
type Axis = "x" | "y";
export function ScrollArea({
children,
orientation = "vertical",
type = "hover",
scrollHideDelay = 700,
fadeEdges = true,
fadeSize = 28,
className,
viewportClassName,
viewportRef,
"aria-label": ariaLabel,
onPointerEnter,
onPointerLeave,
...rest
}: ScrollAreaProps) {
const reduce = useReducedMotion();
const viewport = useRef<HTMLElement>(null);
const content = useRef<HTMLDivElement>(null);
const thumbs = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });
const tracks = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const hovering = useRef(false);
const dragging = useRef<{
axis: Axis;
pointer: number;
scroll: number;
ratio: number;
} | null>(null);
const [overflow, setOverflow] = useState({ x: false, y: false });
const [visible, setVisible] = useState(false);
useImperativeHandle(viewportRef, () => viewport.current as HTMLElement, []);
const allowX = orientation !== "vertical";
const allowY = orientation !== "horizontal";
// Geometry is written straight to the DOM on every scroll frame so a long
// list never re-renders React while scrolling.
const update = useCallback(() => {
const el = viewport.current;
if (!el) return;
const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth } = el;
const hasY = allowY && scrollHeight - clientHeight > 1;
const hasX = allowX && scrollWidth - clientWidth > 1;
setOverflow((prev) => (prev.x === hasX && prev.y === hasY ? prev : { x: hasX, y: hasY }));
const place = (axis: Axis) => {
const thumb = thumbs.current[axis];
const track = tracks.current[axis];
if (!thumb || !track) return;
const trackLen = axis === "y" ? track.clientHeight : track.clientWidth;
const view = axis === "y" ? clientHeight : clientWidth;
const total = axis === "y" ? scrollHeight : scrollWidth;
const pos = axis === "y" ? scrollTop : scrollLeft;
const len = Math.max(MIN_THUMB, Math.min(trackLen, (view / total) * trackLen));
const max = total - view;
const offset = max > 0 ? (pos / max) * (trackLen - len) : 0;
// The thumb's length is set (not animated) whenever content resizes;
// its motion along the track is a transform.
if (axis === "y") {
thumb.style.height = `${len}px`;
thumb.style.transform = `translate3d(0, ${offset}px, 0)`;
} else {
thumb.style.width = `${len}px`;
thumb.style.transform = `translate3d(${offset}px, 0, 0)`;
}
};
if (hasY) place("y");
if (hasX) place("x");
if (fadeEdges) {
// Fade length tracks the distance to each edge, so the mask eases in
// and out as you leave or reach an end instead of popping.
const top = hasY ? Math.min(fadeSize, scrollTop) : 0;
const bottom = hasY ? Math.min(fadeSize, scrollHeight - clientHeight - scrollTop) : 0;
const left = hasX ? Math.min(fadeSize, scrollLeft) : 0;
const right = hasX ? Math.min(fadeSize, scrollWidth - clientWidth - scrollLeft) : 0;
el.style.setProperty("--sa-top", `${Math.max(0, top)}px`);
el.style.setProperty("--sa-bottom", `${Math.max(0, bottom)}px`);
el.style.setProperty("--sa-left", `${Math.max(0, left)}px`);
el.style.setProperty("--sa-right", `${Math.max(0, right)}px`);
}
}, [allowX, allowY, fadeEdges, fadeSize]);
const scheduleHide = useCallback(() => {
clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => {
if (dragging.current) return;
if (type === "hover" && hovering.current) return;
setVisible(false);
}, scrollHideDelay);
}, [scrollHideDelay, type]);
const reveal = useCallback(() => {
setVisible(true);
scheduleHide();
}, [scheduleHide]);
useEffect(() => {
const el = viewport.current;
const inner = content.current;
if (!el || !inner) return;
update();
const observer = new ResizeObserver(() => update());
observer.observe(el);
observer.observe(inner);
return () => observer.disconnect();
}, [update]);
// Bars mount only once overflow is known; place their thumbs right after.
useEffect(() => {
if (overflow.x || overflow.y) update();
}, [overflow.x, overflow.y, update]);
useEffect(() => () => clearTimeout(hideTimer.current), []);
const onScroll = () => {
update();
if (type !== "always") reveal();
};
const startDrag = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
const el = viewport.current;
const track = tracks.current[axis];
const thumb = thumbs.current[axis];
if (!el || !track || !thumb || event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const trackLen = axis === "y" ? track.clientHeight : track.clientWidth;
const thumbLen = axis === "y" ? thumb.offsetHeight : thumb.offsetWidth;
const max = axis === "y" ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth;
dragging.current = {
axis,
pointer: axis === "y" ? event.clientY : event.clientX,
scroll: axis === "y" ? el.scrollTop : el.scrollLeft,
ratio: trackLen - thumbLen > 0 ? max / (trackLen - thumbLen) : 0,
};
event.currentTarget.setPointerCapture(event.pointerId);
setVisible(true);
clearTimeout(hideTimer.current);
};
const onDragMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragging.current;
const el = viewport.current;
if (!drag || !el) return;
const delta = (drag.axis === "y" ? event.clientY : event.clientX) - drag.pointer;
if (drag.axis === "y") el.scrollTop = drag.scroll + delta * drag.ratio;
else el.scrollLeft = drag.scroll + delta * drag.ratio;
};
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
dragging.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (type !== "always") scheduleHide();
};
// Clicking the empty track pages one viewport toward the click, like a
// native scrollbar track.
const pageTrack = (axis: Axis) => (event: ReactPointerEvent<HTMLDivElement>) => {
const el = viewport.current;
const thumb = thumbs.current[axis];
if (!el || !thumb || event.button !== 0 || event.target !== event.currentTarget) return;
event.preventDefault();
const rect = thumb.getBoundingClientRect();
const before = axis === "y" ? event.clientY < rect.top : event.clientX < rect.left;
const page = (axis === "y" ? el.clientHeight : el.clientWidth) * PAGE_FACTOR;
const amount = before ? -page : page;
el.scrollBy({
top: axis === "y" ? amount : 0,
left: axis === "x" ? amount : 0,
behavior: reduce ? "auto" : "smooth",
});
};
const fade = "linear-gradient(to bottom, transparent 0, black var(--sa-top), black calc(100% - var(--sa-bottom)), transparent 100%)";
const fadeX = "linear-gradient(to right, transparent 0, black var(--sa-left), black calc(100% - var(--sa-right)), transparent 100%)";
const mask = fadeEdges
? allowX && allowY
? `${fade}, ${fadeX}`
: allowX
? fadeX
: fade
: undefined;
const viewportStyle: CSSProperties | undefined = mask
? ({
"--sa-top": "0px",
"--sa-bottom": "0px",
"--sa-left": "0px",
"--sa-right": "0px",
maskImage: mask,
WebkitMaskImage: mask,
maskComposite: allowX && allowY ? "intersect" : undefined,
WebkitMaskComposite: allowX && allowY ? "source-in" : undefined,
} as CSSProperties)
: undefined;
const shown = type === "always" || visible;
const both = overflow.x && overflow.y;
const renderBar = (axis: Axis) => (
<motion.div
aria-hidden
ref={(node: HTMLDivElement | null) => {
tracks.current[axis] = node;
}}
data-orientation={axis === "y" ? "vertical" : "horizontal"}
initial={false}
animate={{ opacity: shown ? 1 : 0 }}
transition={{ duration: shown ? 0.15 : 0.3, ease: EASE_OUT }}
onPointerDown={pageTrack(axis)}
className={cn(
"group/scroll-track absolute z-10 flex touch-none select-none",
!shown && "pointer-events-none",
axis === "y" ? "w-2.5 justify-center" : "h-2.5 flex-col justify-center",
)}
style={
axis === "y"
? { top: TRACK_INSET, right: TRACK_INSET, bottom: both ? 12 : TRACK_INSET }
: { left: TRACK_INSET, bottom: TRACK_INSET, right: both ? 12 : TRACK_INSET }
}
>
<div
ref={(node) => {
thumbs.current[axis] = node;
}}
onPointerDown={startDrag(axis)}
onPointerMove={onDragMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={cn(
"absolute rounded-full bg-foreground/25 transition-colors hover:bg-foreground/45 active:bg-foreground/55 will-change-transform",
axis === "y" ? "top-0 w-1.5 group-hover/scroll-track:w-2" : "left-0 h-1.5 group-hover/scroll-track:h-2",
)}
/>
</motion.div>
);
return (
<div
className={cn("relative overflow-hidden", className)}
onPointerEnter={(event) => {
onPointerEnter?.(event);
hovering.current = true;
if (type === "hover") {
setVisible(true);
clearTimeout(hideTimer.current);
}
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
hovering.current = false;
if (type === "hover") scheduleHide();
}}
{...rest}
>
{/* The viewport is focusable (tabIndex 0) so keyboard users can scroll
it (WCAG 2.1.1); pass `aria-label` to name the region. */}
<section
ref={viewport}
aria-label={ariaLabel}
// biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be focusable for keyboard scrolling (WCAG 2.1.1).
tabIndex={0}
onScroll={onScroll}
style={viewportStyle}
className={cn(
"size-full outline-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
"focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset",
allowY ? "overflow-y-auto" : "overflow-y-hidden",
allowX ? "overflow-x-auto" : "overflow-x-hidden",
viewportClassName,
)}
>
{/* Horizontal content is laid out at its own width so the observer
sees it grow; vertical content keeps the viewport's width. */}
<div ref={content} className={cn(allowX ? "w-max min-w-full" : "min-w-full")}>
{children}
</div>
</section>
{overflow.y ? renderBar("y") : null}
{overflow.x ? renderBar("x") : null}
</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
entriesChangelogEntry[]—filter?"all" | "fixed" | "new" | "improved"—defaultFilter?"all" | "fixed" | "new" | "improved"allonFilterChange?((filter: ChangelogFilter) => void)—expanded?string[]Ids of expanded entries. Default: the newest entry.
—defaultExpanded?string[]—onExpandedChange?((expanded: string[]) => void)—onSubscribe?((email: string) => unknown)Shows the subscribe field. Reject (or throw) to show an error state.
—subscribeLabel?stringGet release notes by emaillocale?stringen-USclassName?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