Conversation History
Chat history sidebar list grouped into Pinned, Today, Yesterday, Previous 7 days and Older from a now read after mount, with search, a spring-gliding active highlight, hover or focus row actions to pin, rename in place through InlineEdit and delete with a slide-out exit while neighbours glide up, and new chats that drop in at the top with a streaming title caret.
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))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/agents/conversation-history
import { MessageSquare, Pencil, Pin, PinOff, Plus, Search, Trash2 } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
} from "motion/react";
import {
type CSSProperties,
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { EmptyState } from "@/components/motion/empty-state";
import { InlineEdit } from "@/components/motion/inline-edit";
import { ScrollArea } from "@/components/motion/scroll-area";
import { Skeleton } from "@/components/motion/skeleton";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface Conversation {
id: string;
title: string;
/** Last activity. ISO string, epoch ms or Date. */
updatedAt: string | number | Date;
pinned?: boolean;
/** The title is still being generated: shows a blinking caret after it. */
streaming?: boolean;
}
export type ConversationGroup = "pinned" | "today" | "yesterday" | "week" | "older";
export interface ConversationHistoryProps {
conversations?: Conversation[];
defaultConversations?: Conversation[];
onConversationsChange?: (conversations: Conversation[]) => void;
activeId?: string | null;
defaultActiveId?: string | null;
onActiveIdChange?: (id: string) => void;
/** Called after a rename commits; return a promise to keep the editor's spinner until it settles. */
onRename?: (id: string, title: string) => unknown;
onPinChange?: (id: string, pinned: boolean) => void;
onDelete?: (id: string) => void;
/** Renders the "New chat" button. The parent inserts the conversation (usually `streaming`). */
onNewChat?: () => void;
/** Override "today" (otherwise read after mount so server and client markup match). */
now?: Date;
searchable?: boolean;
labels?: Partial<Record<ConversationGroup, string>>;
/** Max height of the scrolling list. Default 320. */
maxHeight?: number;
emptyState?: ReactNode;
className?: string;
}
const GROUP_LABELS: Record<ConversationGroup, string> = {
pinned: "Pinned",
today: "Today",
yesterday: "Yesterday",
week: "Previous 7 days",
older: "Older",
};
const GROUP_ORDER: ConversationGroup[] = ["pinned", "today", "yesterday", "week", "older"];
const DAY_MS = 86_400_000;
function toTime(value: Conversation["updatedAt"]) {
return value instanceof Date ? value.getTime() : new Date(value).getTime();
}
/** Bucket a conversation by calendar day relative to `now` (local time). */
export function groupConversation(conversation: Conversation, now: Date): ConversationGroup {
if (conversation.pinned) return "pinned";
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const t = toTime(conversation.updatedAt);
if (t >= startOfToday) return "today";
if (t >= startOfToday - DAY_MS) return "yesterday";
if (t >= startOfToday - 7 * DAY_MS) return "week";
return "older";
}
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 ConversationHistory({
conversations: conversationsProp,
defaultConversations = [],
onConversationsChange,
activeId: activeIdProp,
defaultActiveId = null,
onActiveIdChange,
onRename,
onPinChange,
onDelete,
onNewChat,
now: nowProp,
searchable = true,
labels,
maxHeight = 320,
emptyState,
className,
}: ConversationHistoryProps) {
const uid = useId();
const reduce = useReducedMotion() ?? false;
const [conversations, setConversations] = useControllable(
conversationsProp,
defaultConversations,
onConversationsChange,
);
const [activeId, setActiveId] = useControllable<string | null>(
activeIdProp,
defaultActiveId,
onActiveIdChange as ((id: string | null) => void) | undefined,
);
const [query, setQuery] = useState("");
const [renamingId, setRenamingId] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
// "Today" is only known on the client; grouping waits for it so the server
// render never disagrees with the first client render.
const [mountedNow, setMountedNow] = useState<Date | null>(null);
useEffect(() => {
if (!nowProp) setMountedNow(new Date());
}, [nowProp]);
const now = nowProp ?? mountedNow;
const groups = useMemo(() => {
if (!now) return [];
const q = query.trim().toLowerCase();
const buckets = new Map<ConversationGroup, Conversation[]>();
const sorted = [...conversations].sort((a, b) => toTime(b.updatedAt) - toTime(a.updatedAt));
for (const c of sorted) {
if (q && !c.title.toLowerCase().includes(q)) continue;
const g = groupConversation(c, now);
const list = buckets.get(g) ?? [];
list.push(c);
buckets.set(g, list);
}
return GROUP_ORDER.filter((g) => buckets.has(g)).map((g) => ({
id: g,
items: buckets.get(g) ?? [],
}));
}, [conversations, now, query]);
const focusRow = (id: string) => {
requestAnimationFrame(() => {
listRef.current
?.querySelector<HTMLElement>(`[data-conversation-id="${CSS.escape(id)}"]`)
?.focus();
});
};
const update = (id: string, patch: Partial<Conversation>) =>
setConversations(conversations.map((c) => (c.id === id ? { ...c, ...patch } : c)));
const remove = (id: string) => {
const flat = groups.flatMap((g) => g.items);
const index = flat.findIndex((c) => c.id === id);
const neighbour = flat[index + 1] ?? flat[index - 1];
setConversations(conversations.filter((c) => c.id !== id));
onDelete?.(id);
if (neighbour) focusRow(neighbour.id);
};
// Up/Down (and Home/End) move between conversation rows.
const onListKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const target = event.target as HTMLElement;
if (!target.dataset.conversationId) return;
const rows = Array.from(
listRef.current?.querySelectorAll<HTMLElement>("[data-conversation-id]") ?? [],
);
const i = rows.indexOf(target);
const next =
event.key === "Home"
? rows[0]
: event.key === "End"
? rows[rows.length - 1]
: rows[i + (event.key === "ArrowDown" ? 1 : -1)];
if (next) {
event.preventDefault();
next.focus();
}
};
const total = groups.reduce((n, g) => n + g.items.length, 0);
return (
<div className={cn("flex w-full flex-col gap-2", className)}>
{(onNewChat || searchable) && (
<div className="flex items-center gap-2">
{searchable && (
<label className="relative flex h-9 min-w-0 flex-1 items-center rounded-lg border border-border bg-muted/50 focus-within:border-border-strong focus-within:ring-2 focus-within:ring-ring/30">
<Search aria-hidden="true" className="pointer-events-none absolute left-2.5 size-3.5 text-muted-foreground" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search chats"
aria-label="Search chats"
className="h-full w-full bg-transparent pl-8 pr-2 text-sm text-foreground outline-none placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:hidden"
/>
</label>
)}
{onNewChat && (
<button
type="button"
onClick={onNewChat}
aria-label="New chat"
className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
>
<Plus aria-hidden="true" className="size-4" />
<span className={searchable ? "sr-only sm:not-sr-only" : undefined}>New chat</span>
</button>
)}
</div>
)}
<ScrollArea
aria-label="Conversation history"
fadeEdges
className="w-full"
viewportClassName="max-h-[var(--ch-max)]"
style={{ "--ch-max": `${maxHeight}px` } as CSSProperties}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: delegates arrow keys to the row buttons inside */}
<div ref={listRef} onKeyDown={onListKeyDown} className="flex flex-col gap-3 p-1">
{!now ? (
<div aria-busy="true" className="flex flex-col gap-2 px-1">
<Skeleton shape="text" width="30%" />
{[72, 58, 80, 64].map((w) => (
<Skeleton key={w} height={32} width={`${w}%`} />
))}
</div>
) : total === 0 ? (
(emptyState ?? (
<EmptyState
size="sm"
icon={<MessageSquare aria-hidden="true" className="size-5" />}
title={query ? "No matching chats" : "No conversations yet"}
description={query ? `Nothing matches “${query}”.` : "Start a new chat to see it here."}
/>
))
) : (
<LayoutGroup id={uid}>
<AnimatePresence initial={false} mode="popLayout">
{groups.map((group) => (
<motion.section
key={group.id}
layout={reduce ? false : "position"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={SPRING_LAYOUT}
aria-labelledby={`${uid}-${group.id}`}
className="flex flex-col gap-0.5"
>
<h3
id={`${uid}-${group.id}`}
className="flex items-center gap-1.5 px-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
>
{group.id === "pinned" && <Pin aria-hidden="true" className="size-3" />}
{labels?.[group.id] ?? GROUP_LABELS[group.id]}
</h3>
<ul className="flex flex-col gap-0.5">
<AnimatePresence initial={false} mode="popLayout">
{group.items.map((conversation) => (
<ConversationRow
key={conversation.id}
uid={uid}
conversation={conversation}
active={conversation.id === activeId}
renaming={renamingId === conversation.id}
reduce={reduce}
onSelect={() => setActiveId(conversation.id)}
onStartRename={() => setRenamingId(conversation.id)}
onEndRename={() => {
setRenamingId(null);
focusRow(conversation.id);
}}
onRename={(title) => {
update(conversation.id, { title });
return onRename?.(conversation.id, title);
}}
onTogglePin={() => {
const pinned = !conversation.pinned;
update(conversation.id, { pinned });
onPinChange?.(conversation.id, pinned);
focusRow(conversation.id);
}}
onDelete={() => remove(conversation.id)}
/>
))}
</AnimatePresence>
</ul>
</motion.section>
))}
</AnimatePresence>
</LayoutGroup>
)}
</div>
</ScrollArea>
<p aria-live="polite" className="sr-only">
{query && now ? `${total} conversation${total === 1 ? "" : "s"} found` : ""}
</p>
</div>
);
}
interface ConversationRowProps {
uid: string;
conversation: Conversation;
active: boolean;
renaming: boolean;
reduce: boolean;
onSelect: () => void;
onStartRename: () => void;
onEndRename: () => void;
onRename: (title: string) => unknown;
onTogglePin: () => void;
onDelete: () => void;
}
function ConversationRow({
uid,
conversation,
active,
renaming,
reduce,
onSelect,
onStartRename,
onEndRename,
onRename,
onTogglePin,
onDelete,
}: ConversationRowProps) {
const editRef = useRef<HTMLDivElement>(null);
// InlineEdit opens from its own display button; press it once so the
// Rename action lands straight in the editor.
useEffect(() => {
if (renaming) editRef.current?.querySelector<HTMLButtonElement>("button")?.click();
}, [renaming]);
return (
<motion.li
layout={reduce ? false : "position"}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.12 } }
: { opacity: 0, x: -24, transition: { duration: 0.18, ease: EASE_OUT } }
}
transition={SPRING_LAYOUT}
className="group/row relative"
>
{active && (
<motion.span
aria-hidden="true"
layoutId={`${uid}-active`}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className="absolute inset-0 rounded-lg bg-muted ring-1 ring-border"
/>
)}
{renaming ? (
<div ref={editRef} className="relative px-2 py-0.5 text-sm">
<InlineEdit
label="Conversation title"
defaultValue={conversation.title}
maxLength={80}
validate={(next) => (next.trim() ? null : "Title can't be empty")}
onSave={(next) => onRename(next.trim())}
onEditingChange={(editing) => {
if (!editing) onEndRename();
}}
className="mx-0"
/>
</div>
) : (
<>
<button
type="button"
data-conversation-id={conversation.id}
aria-current={active ? "page" : undefined}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "F2") {
e.preventDefault();
onStartRename();
} else if (e.key === "Delete") {
e.preventDefault();
onDelete();
}
}}
className={cn(
"relative flex h-9 w-full items-center rounded-lg px-2.5 pr-2 text-left text-sm outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring",
active ? "text-foreground" : "text-muted-foreground hover:bg-foreground/5 hover:text-foreground",
)}
>
<span className="min-w-0 flex-1 truncate group-focus-within/row:pr-20 group-hover/row:pr-20">
{conversation.title ||
(conversation.streaming ? (
<span className="text-muted-foreground">New chat</span>
) : (
"Untitled"
))}
{conversation.streaming && (
<motion.span
aria-hidden="true"
className="ml-0.5 inline-block h-3.5 w-px translate-y-0.5 bg-foreground"
animate={{ opacity: [1, 0, 1] }}
transition={{ duration: 1, repeat: Number.POSITIVE_INFINITY }}
/>
)}
</span>
{conversation.streaming && <span className="sr-only">(generating title)</span>}
</button>
<div
className={cn(
"absolute inset-y-0 right-1 flex items-center gap-0.5 opacity-0 transition-opacity duration-150",
"group-focus-within/row:opacity-100 group-hover/row:opacity-100 pointer-coarse:opacity-100",
)}
>
<RowAction label={conversation.pinned ? "Unpin" : "Pin"} onClick={onTogglePin}>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={conversation.pinned ? "off" : "on"}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, rotate: -30 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.5 }}
transition={SPRING_SWAP}
className="flex"
>
{conversation.pinned ? <PinOff className="size-3.5" /> : <Pin className="size-3.5" />}
</motion.span>
</AnimatePresence>
</RowAction>
<RowAction label="Rename" onClick={onStartRename}>
<Pencil className="size-3.5" />
</RowAction>
<RowAction label="Delete" destructive onClick={onDelete}>
<Trash2 className="size-3.5" />
</RowAction>
</div>
</>
)}
</motion.li>
);
}
function RowAction({
label,
destructive,
onClick,
children,
}: {
label: string;
destructive?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={cn(
"flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring",
destructive
? "hover:bg-destructive/10 hover:text-destructive"
: "hover:bg-foreground/10 hover:text-foreground",
)}
>
{children}
</button>
);
}
"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 {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
} from "react";
import { Loader } from "@/components/motion/loader";
import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type InlineEditClassNames = {
root?: string;
display?: string;
input?: string;
placeholder?: string;
errorMessage?: string;
};
export interface InlineEditProps {
/** Accessible name of the field ("Name", "Bio"); also used in the edit hint. */
label: string;
value?: string;
defaultValue?: string;
/**
* Commit handler. Return a promise to show a pending spinner; the shown value
* rolls back (with a shake and the rejection message) if it rejects.
* Controlled callers update `value` here.
*/
onSave?: (next: string) => unknown;
onCancel?: () => void;
onEditingChange?: (editing: boolean) => void;
/** Return an error message to block the save and keep the editor open. */
validate?: (next: string) => string | null | undefined | false;
/** Edit in an auto-growing textarea; Enter saves, Shift+Enter adds a line. */
multiline?: boolean;
placeholder?: string;
maxLength?: number;
/** Select the whole text when the editor opens (default) or put the caret at the end. */
selectOnEdit?: boolean;
disabled?: boolean;
className?: string;
classNames?: InlineEditClassNames;
}
type Status = "idle" | "pending" | "saved";
const SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] };
const SAVED_FLASH_MS = 1400;
function messageOf(reason: unknown) {
if (reason instanceof Error && reason.message) return reason.message;
if (typeof reason === "string" && reason) return reason;
return "Couldn't save. Try again.";
}
function isThenable(value: unknown): value is PromiseLike<unknown> {
return (
typeof value === "object" &&
value !== null &&
typeof (value as PromiseLike<unknown>).then === "function"
);
}
export function InlineEdit({
label,
value: valueProp,
defaultValue,
onSave,
onCancel,
onEditingChange,
validate,
multiline = false,
placeholder = "Empty",
maxLength,
selectOnEdit = true,
disabled,
className,
classNames,
}: InlineEditProps) {
const id = useId();
const reduce = useReducedMotion();
const controlled = valueProp !== undefined;
const [internal, setInternal] = useState(defaultValue ?? "");
const value = controlled ? (valueProp ?? "") : internal;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [status, setStatus] = useState<Status>("idle");
const [pendingValue, setPendingValue] = useState("");
const [error, setError] = useState<string | null>(null);
// Refs gate commit/cancel so the blur fired while the editor unmounts can't
// save a second time.
const editingRef = useRef(false);
const restoreFocusRef = useRef(false);
const saveIdRef = useRef(0);
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const boxRef = useRef<HTMLDivElement>(null);
const displayRef = useRef<HTMLButtonElement>(null);
const fieldRef = useRef<HTMLInputElement & HTMLTextAreaElement>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (flashTimer.current) clearTimeout(flashTimer.current);
};
}, []);
const shake = useCallback(() => {
if (!boxRef.current || reduce) return;
animate(boxRef.current, SHAKE, { duration: 0.45 });
}, [reduce]);
const setEditingState = (next: boolean) => {
editingRef.current = next;
setEditing(next);
onEditingChange?.(next);
};
// Focus the editor as it opens; return focus to the text after a
// keyboard commit or cancel (not after blur — that would steal focus).
useEffect(() => {
if (editing) {
const field = fieldRef.current;
if (!field) return;
field.focus();
if (selectOnEdit) field.select();
else field.setSelectionRange(field.value.length, field.value.length);
} else if (restoreFocusRef.current) {
restoreFocusRef.current = false;
displayRef.current?.focus();
}
}, [editing, selectOnEdit]);
const startEditing = () => {
if (disabled || editingRef.current || status === "pending") return;
if (flashTimer.current) clearTimeout(flashTimer.current);
setStatus("idle");
setDraft(value);
setError(null);
setEditingState(true);
};
const cancel = () => {
if (!editingRef.current) return;
restoreFocusRef.current = true;
setDraft(value);
setError(null);
setEditingState(false);
onCancel?.();
};
const flashSaved = () => {
setStatus("saved");
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => {
if (mountedRef.current) setStatus("idle");
}, SAVED_FLASH_MS);
};
const commit = (via: "key" | "blur") => {
if (!editingRef.current) return;
const next = draft;
if (next === value) {
if (via === "key") restoreFocusRef.current = true;
setError(null);
setEditingState(false);
return;
}
const invalid = validate?.(next);
if (invalid) {
setError(invalid);
shake();
return;
}
if (via === "key") restoreFocusRef.current = true;
setError(null);
setEditingState(false);
const result = onSave?.(next);
if (!isThenable(result)) {
if (!controlled) setInternal(next);
flashSaved();
return;
}
const saveId = ++saveIdRef.current;
setPendingValue(next);
setStatus("pending");
Promise.resolve(result).then(
() => {
if (!mountedRef.current || saveId !== saveIdRef.current) return;
if (!controlled) setInternal(next);
flashSaved();
},
(reason: unknown) => {
if (!mountedRef.current || saveId !== saveIdRef.current) return;
setStatus("idle");
setError(messageOf(reason));
shake();
},
);
};
const onFieldKeyDown = (
event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
cancel();
} else if (event.key === "Enter" && !(multiline && event.shiftKey)) {
event.preventDefault();
commit("key");
}
};
const shown = status === "pending" ? pendingValue : value;
const errorId = `${id}-error`;
// Display and editor share this box model exactly, so swapping them never
// moves the text. The -mx-2 hangs the padding outside so text aligns with
// surrounding content.
const textBox = cn(
"w-full rounded-lg px-2 py-1 pr-8 text-left",
multiline ? "whitespace-pre-wrap break-words" : "",
);
const iconSlot = cn(
"pointer-events-none absolute right-2 z-10 flex size-4 items-center justify-center",
multiline ? "top-[calc(0.25rem+0.5lh)] -translate-y-1/2" : "top-1/2 -translate-y-1/2",
);
const motionIn = reduce
? { opacity: 1 }
: { opacity: 1, scale: 1 };
const motionOut = reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.6 };
return (
<div
data-state={editing ? "editing" : status}
className={cn("relative -mx-2", className, classNames?.root)}
>
<div ref={boxRef} className="group relative">
{/* Field surface: fades and settles in under the text while editing. */}
<motion.span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 rounded-lg border bg-background ring-2",
error
? "border-destructive ring-destructive/25"
: "border-foreground/30 ring-ring/40",
)}
initial={false}
animate={
editing
? motionIn
: reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.98 }
}
transition={{ duration: editing ? 0.2 : 0.12, ease: EASE_OUT }}
/>
{editing ? (
multiline ? (
<div className="relative grid">
<span
aria-hidden="true"
className={cn(
textBox,
"invisible [grid-area:1/1]",
classNames?.input,
)}
>
{`${draft} `}
</span>
<textarea
ref={fieldRef}
aria-label={label}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
rows={1}
value={draft}
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => {
setDraft(e.target.value);
if (error) setError(null);
}}
onKeyDown={onFieldKeyDown}
onBlur={() => commit("blur")}
className={cn(
textBox,
"relative resize-none overflow-hidden bg-transparent text-foreground caret-foreground outline-none [grid-area:1/1] placeholder:text-muted-foreground/60",
classNames?.input,
)}
/>
</div>
) : (
<input
ref={fieldRef}
aria-label={label}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
value={draft}
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => {
setDraft(e.target.value);
if (error) setError(null);
}}
onKeyDown={onFieldKeyDown}
onBlur={() => commit("blur")}
className={cn(
textBox,
"relative block bg-transparent text-foreground caret-foreground outline-none placeholder:text-muted-foreground/60",
classNames?.input,
)}
/>
)
) : (
<button
ref={displayRef}
type="button"
disabled={disabled}
aria-describedby={error ? errorId : undefined}
onClick={startEditing}
onKeyDown={(event) => {
if (event.key === "F2") {
event.preventDefault();
startEditing();
}
}}
className={cn(
textBox,
"relative block cursor-text transition-[background-color,opacity] duration-150",
"outline-none hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring/50",
!multiline && "truncate",
status === "pending" && "opacity-70",
disabled && "cursor-not-allowed hover:bg-transparent",
classNames?.display,
)}
>
{shown ? (
shown
) : (
<span
className={cn(
"text-muted-foreground/70 italic",
classNames?.placeholder,
)}
>
{placeholder}
</span>
)}
<span className="sr-only">, edit {label}</span>
</button>
)}
<span className={iconSlot}>
<AnimatePresence initial={false} mode="popLayout">
{status === "pending" ? (
<motion.span
key="pending"
className="flex"
initial={motionOut}
animate={motionIn}
exit={motionOut}
transition={SPRING_SWAP}
>
<Loader
variant="spinner"
size={14}
label="Saving"
className="text-muted-foreground"
/>
</motion.span>
) : status === "saved" ? (
<motion.span
key="saved"
className="flex size-4 items-center justify-center rounded-full bg-success text-background"
initial={motionOut}
animate={motionIn}
exit={{ opacity: 0 }}
transition={SPRING_PRESS}
>
<svg viewBox="0 0 24 24" fill="none" className="size-3" aria-hidden="true">
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.05 }}
/>
</svg>
</motion.span>
) : !editing && !disabled ? (
<motion.span
key="pencil"
className="flex text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<span className="flex opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
aria-hidden="true"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</span>
</motion.span>
) : null}
</AnimatePresence>
</span>
</div>
<span className="sr-only" aria-live="polite">
{status === "pending" ? "Saving" : status === "saved" ? "Saved" : ""}
</span>
<AnimatePresence initial={false}>
{error ? (
<motion.p
id={errorId}
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-2 pt-1 text-xs text-destructive",
classNames?.errorMessage,
)}
>
{error}
</motion.p>
) : null}
</AnimatePresence>
</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>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type CSSProperties,
type ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { EASE_IN_OUT, EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type SkeletonShape = "rect" | "circle" | "text";
export interface SkeletonProps {
shape?: SkeletonShape;
/** CSS width (number = px). Text defaults to 100%, circles to 40px. */
width?: number | string;
/** CSS height (number = px). Circles default to their width. */
height?: number | string;
/** Sweep a highlight across the block. Reduced motion swaps it for a pulse. */
shimmer?: boolean;
className?: string;
style?: CSSProperties;
}
const SHAPE_CLASS: Record<SkeletonShape, string> = {
rect: "rounded-lg",
circle: "rounded-full shrink-0",
text: "h-3.5 rounded-md",
};
// Reduced motion: no sweep; the block breathes in opacity only.
const PULSE = {
animate: { opacity: [1, 0.55, 1] },
transition: { duration: 1.6, ease: EASE_IN_OUT, repeat: Infinity },
};
/**
* A placeholder block. The shimmer is a gradient strip translated across the
* block (transform only), clipped by the block's own overflow.
*/
export function Skeleton({
shape = "rect",
width,
height,
shimmer = true,
className,
style,
}: SkeletonProps) {
const reduce = useReducedMotion();
const resolvedWidth = width ?? (shape === "circle" ? 40 : undefined);
const resolvedHeight = height ?? (shape === "circle" ? resolvedWidth : undefined);
return (
<motion.div
aria-hidden="true"
className={cn(
"relative overflow-hidden bg-muted",
SHAPE_CLASS[shape],
shape === "text" && "w-full",
className,
)}
style={{ width: resolvedWidth, height: resolvedHeight, ...style }}
animate={reduce && shimmer ? PULSE.animate : undefined}
transition={reduce && shimmer ? PULSE.transition : undefined}
>
{shimmer && !reduce ? (
<motion.span
className="absolute inset-y-0 left-0 w-full bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--foreground)_9%,transparent),transparent)]"
initial={{ x: "-100%" }}
animate={{ x: "100%" }}
transition={{
duration: 1.5,
ease: EASE_IN_OUT,
repeat: Infinity,
repeatDelay: 0.3,
}}
/>
) : null}
</motion.div>
);
}
export interface SkeletonTextProps {
/** Number of lines. */
lines?: number;
/** Width of the final line, so the block reads like a real paragraph. */
lastLineWidth?: number | string;
/** Gap between lines (Tailwind class). */
gapClassName?: string;
lineClassName?: string;
shimmer?: boolean;
className?: string;
}
/** A paragraph of text lines with a shorter, natural-looking last line. */
export function SkeletonText({
lines = 3,
lastLineWidth = "62%",
gapClassName = "gap-2",
lineClassName,
shimmer = true,
className,
}: SkeletonTextProps) {
const count = Math.max(1, lines);
return (
<div aria-hidden="true" className={cn("flex w-full flex-col", gapClassName, className)}>
{Array.from({ length: count }, (_, i) => (
<Skeleton
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional and never reorder
key={i}
shape="text"
shimmer={shimmer}
className={lineClassName}
width={count > 1 && i === count - 1 ? lastLineWidth : "100%"}
/>
))}
</div>
);
}
export interface SkeletonSwapProps {
/** While true the skeleton shows; flip to false to cross-fade to children. */
loading: boolean;
/** Placeholder rendered while loading — mirror the real content's shape. */
skeleton: ReactNode;
/** Real content. May be absent while loading. */
children?: ReactNode;
/**
* Keep the last measured content height while reloading, so a reload never
* collapses the box to the skeleton's height. Default true.
*/
keepHeight?: boolean;
/** Announced to screen readers while loading. */
loadingLabel?: string;
className?: string;
}
/**
* Cross-fades from a skeleton to real content without layout shift: both
* layers share one grid cell during the fade (the box takes the taller of the
* two), and the last measured content height is held as a min-height while a
* reload shows the skeleton again.
*/
export function SkeletonSwap({
loading,
skeleton,
children,
keepHeight = true,
loadingLabel = "Loading",
className,
}: SkeletonSwapProps) {
const reduce = useReducedMotion();
const contentRef = useRef<HTMLDivElement>(null);
const [measured, setMeasured] = useState<number | null>(null);
useEffect(() => {
const node = contentRef.current;
if (loading || !node || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(([entry]) => {
if (entry) setMeasured(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight);
});
ro.observe(node);
return () => ro.disconnect();
}, [loading]);
return (
<div
aria-busy={loading}
className={cn("relative grid", className)}
style={keepHeight && loading && measured != null ? { minHeight: measured } : undefined}
>
<span className="sr-only" aria-live="polite">
{loading ? loadingLabel : ""}
</span>
<AnimatePresence initial={false}>
{loading ? (
<motion.div
key="skeleton"
className="[grid-area:1/1]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.18, ease: EASE_OUT } }}
transition={{ duration: 0.2, ease: EASE_OUT }}
>
{skeleton}
</motion.div>
) : (
<motion.div
key="content"
ref={contentRef}
className="[grid-area:1/1]"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } }}
transition={{ duration: 0.28, ease: EASE_OUT }}
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type LoaderVariant =
| "spinner"
| "dots"
| "bars"
| "dot-matrix"
| "dither"
| "ascii"
| "ascii-line"
| "ascii-braille"
| "ascii-blocks"
| "ascii-bounce"
| "morph"
| "comet"
| "scramble"
| "metaballs"
| "newton"
| "helix"
| "percent";
// Terminal-style frame sets — the loaders CLI AI agents cycle through.
const ASCII_SETS: Record<string, string[]> = {
ascii: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
"ascii-line": ["|", "/", "-", "\\"],
"ascii-braille": ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
"ascii-blocks": ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂"],
"ascii-bounce": ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
};
export interface LoaderProps {
/** Which animation to render. */
variant?: LoaderVariant;
/** Base square size in px. Everything scales from this. */
size?: number;
/** Seconds per animation cycle. */
speed?: number;
/** Accessible label announced to screen readers. */
label?: string;
className?: string;
}
// Reduced motion keeps a calm opacity pulse and drops every transform.
const REDUCED = {
animate: { opacity: [1, 0.4, 1] },
transition: { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity },
};
export function Loader({
variant = "spinner",
size = 32,
speed = 1,
label = "Loading",
className,
}: LoaderProps) {
const reduce = useReducedMotion() ?? false;
return (
<span
role="status"
aria-label={label}
className={cn(
"inline-flex items-center justify-center text-foreground",
className,
)}
>
{variant === "spinner" && <Spinner size={size} speed={speed} reduce={reduce} />}
{variant === "dots" && <Dots size={size} speed={speed} reduce={reduce} />}
{variant === "bars" && <Bars size={size} speed={speed} reduce={reduce} />}
{variant === "dot-matrix" && (
<DotMatrix size={size} speed={speed} reduce={reduce} />
)}
{variant === "dither" && <Dither size={size} speed={speed} reduce={reduce} />}
{ASCII_SETS[variant] && (
<Ascii frames={ASCII_SETS[variant]} size={size} speed={speed} reduce={reduce} />
)}
{variant === "morph" && <Morph size={size} speed={speed} reduce={reduce} />}
{variant === "comet" && <Comet size={size} speed={speed} reduce={reduce} />}
{variant === "scramble" && (
<Scramble size={size} speed={speed} reduce={reduce} />
)}
{variant === "metaballs" && (
<Metaballs size={size} speed={speed} reduce={reduce} />
)}
{variant === "newton" && <Newton size={size} speed={speed} reduce={reduce} />}
{variant === "helix" && <Helix size={size} speed={speed} reduce={reduce} />}
{variant === "percent" && (
<Percent size={size} speed={speed} reduce={reduce} />
)}
<span className="sr-only">{label}</span>
</span>
);
}
interface PartProps {
size: number;
speed: number;
reduce: boolean;
}
function Spinner({ size, speed, reduce }: PartProps) {
const stroke = Math.max(2, size * 0.09);
const r = (size - stroke) / 2;
return (
<motion.svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
animate={reduce ? REDUCED.animate : { rotate: 360 }}
transition={
reduce
? REDUCED.transition
: { duration: speed, ease: "linear", repeat: Infinity }
}
>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="currentColor"
strokeOpacity={0.2}
strokeWidth={stroke}
/>
<path
d={`M ${size / 2} ${size / 2 - r} A ${r} ${r} 0 0 1 ${size / 2 + r} ${size / 2}`}
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
/>
</motion.svg>
);
}
function Dots({ size, speed, reduce }: PartProps) {
const dot = size * 0.24;
return (
<span className="flex items-center" style={{ gap: size * 0.14 }}>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="rounded-full bg-current"
style={{ width: dot, height: dot }}
animate={
reduce
? { opacity: [0.4, 1, 0.4] }
: { y: [0, -size * 0.3, 0], opacity: [0.5, 1, 0.5] }
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: i * speed * 0.16,
}}
/>
))}
</span>
);
}
function Ascii({
frames,
size,
speed,
reduce,
}: PartProps & { frames: string[] }) {
const [frame, setFrame] = useState(0);
useEffect(() => {
// Reduced motion slows the cycle rather than stopping it — it's a glyph
// swap, not on-screen movement.
const step = ((reduce ? speed * 2.5 : speed) / frames.length) * 1000;
const id = setInterval(
() => setFrame((f) => (f + 1) % frames.length),
step,
);
return () => clearInterval(id);
}, [frames.length, speed, reduce]);
return (
<span
className="font-mono leading-none tabular-nums"
style={{ fontSize: size, lineHeight: 1 }}
>
{frames[frame % frames.length]}
</span>
);
}
// Each shape is sampled at the same number of points and emitted as an SVG
// path with identical command structure, so framer tweens the `d` attribute
// point-to-point — a real morph, not a snap. (clip-path polygon strings don't
// interpolate reliably in framer, which left the shapes broken.)
const MORPH_POINTS = 24;
function ngonRadius(ang: number, n: number, phase = 0) {
const seg = (2 * Math.PI) / n;
const a = ang - phase;
const local = (((a % seg) + seg) % seg) - seg / 2;
return Math.cos(Math.PI / n) / Math.cos(local);
}
function morphPath(radiusAt: (ang: number) => number) {
const parts: string[] = [];
for (let i = 0; i < MORPH_POINTS; i++) {
const ang = (i / MORPH_POINTS) * 2 * Math.PI - Math.PI / 2;
const r = Math.min(1.05, radiusAt(ang));
const x = (50 + Math.cos(ang) * 46 * r).toFixed(2);
const y = (50 + Math.sin(ang) * 46 * r).toFixed(2);
parts.push(`${i === 0 ? "M" : "L"}${x} ${y}`);
}
return `${parts.join(" ")} Z`;
}
const MORPH_PATHS = [
morphPath(() => 1), // circle
morphPath((a) => ngonRadius(a, 4, Math.PI / 4)), // square
morphPath((a) => ngonRadius(a, 3)), // triangle
morphPath((a) => ngonRadius(a, 6)), // hexagon
morphPath((a) => ngonRadius(a, 4)), // diamond
];
// Each shape appears twice in a row so it fully forms and HOLDS before the
// next morph. Even keyframe spacing then alternates hold / morph segments.
const MORPH_SEQ = [...MORPH_PATHS.flatMap((p) => [p, p]), MORPH_PATHS[0]];
// Rotation and scale only change across the morph segments, staying put on the
// holds, so a settled shape sits still.
const MORPH_ROT = [0, 0, 72, 72, 144, 144, 216, 216, 288, 288, 360];
const MORPH_SCALE = [1, 1, 0.88, 0.88, 1, 1, 0.88, 0.88, 1, 1, 1];
function Morph({ size, speed, reduce }: PartProps) {
return (
<motion.svg
width={size}
height={size}
viewBox="0 0 100 100"
role="img"
animate={
reduce
? { opacity: [1, 0.4, 1] }
: { rotate: MORPH_ROT, scale: MORPH_SCALE }
}
transition={
reduce
? { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity }
: { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }
}
>
<title>Loading</title>
<motion.path
fill="currentColor"
d={MORPH_PATHS[0]}
animate={reduce ? undefined : { d: MORPH_SEQ }}
transition={
reduce
? undefined
: { duration: speed * 5, ease: EASE_IN_OUT, repeat: Infinity }
}
/>
</motion.svg>
);
}
const COMET_TRAIL = [0, 1, 2, 3, 4, 5];
function Comet({ size, speed, reduce }: PartProps) {
const head = size * 0.2;
const r = size / 2 - head / 2;
return (
<span className="relative" style={{ width: size, height: size }}>
<motion.span
className="absolute inset-0"
animate={reduce ? REDUCED.animate : { rotate: 360 }}
transition={
reduce
? REDUCED.transition
: { duration: speed, ease: "linear", repeat: Infinity }
}
>
{COMET_TRAIL.map((i) => {
const scale = 1 - i * 0.13;
const sz = head * scale;
return (
<span
key={i}
className="absolute top-1/2 left-1/2 rounded-full bg-current"
style={{
width: sz,
height: sz,
marginLeft: -sz / 2,
marginTop: -sz / 2,
opacity: 1 - i * 0.16,
transform: `rotate(${-i * 15}deg) translateY(${-r}px)`,
}}
/>
);
})}
</motion.span>
</span>
);
}
const SCRAMBLE_TARGET = "LOADING";
const SCRAMBLE_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>/*#@";
function Scramble({ size, speed, reduce }: PartProps) {
const [text, setText] = useState(SCRAMBLE_TARGET);
useEffect(() => {
if (reduce) {
setText(SCRAMBLE_TARGET);
return;
}
let tick = 0;
const total = SCRAMBLE_TARGET.length + 4;
const id = setInterval(
() => {
const reveal = tick % total;
let s = "";
for (let i = 0; i < SCRAMBLE_TARGET.length; i++) {
s +=
i < reveal
? SCRAMBLE_TARGET[i]
: SCRAMBLE_GLYPHS[
Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)
];
}
setText(s);
tick++;
},
(speed / SCRAMBLE_TARGET.length) * 1000 * 0.55,
);
return () => clearInterval(id);
}, [speed, reduce]);
return (
<span
className="font-mono font-medium tracking-[0.2em] tabular-nums"
style={{ fontSize: size * 0.42 }}
>
{text}
</span>
);
}
function Metaballs({ size, speed, reduce }: PartProps) {
const id = useId().replace(/:/g, "");
return (
<svg width={size} height={size} viewBox="0 0 100 100" role="img">
<title>Loading</title>
<defs>
<filter id={id}>
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="b" />
<feColorMatrix
in="b"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -8"
/>
</filter>
</defs>
<g filter={`url(#${id})`} fill="currentColor">
<motion.circle
cy="50"
r="15"
animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [30, 70, 30] }}
transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}
cx={reduce ? 40 : 30}
/>
<motion.circle
cy="50"
r="15"
animate={reduce ? { opacity: [0.4, 1, 0.4] } : { cx: [70, 30, 70] }}
transition={{ duration: speed * 1.6, ease: EASE_IN_OUT, repeat: Infinity }}
cx={reduce ? 60 : 70}
/>
</g>
</svg>
);
}
const NEWTON_BALLS = [0, 1, 2, 3, 4];
function Newton({ size, speed, reduce }: PartProps) {
const d = size * 0.2;
const out = d * 1.1;
// Only the end balls move: the left slides out and back on the first half,
// then the right on the second half — the impact appears to jump the three
// still middle balls. Pure horizontal slide, no swing, no strings.
const moves: Record<number, { x: number[]; times: number[] }> = {
0: { x: [0, -out, 0, 0], times: [0, 0.28, 0.5, 1] },
4: { x: [0, 0, out, 0], times: [0, 0.5, 0.78, 1] },
};
return (
<span className="flex items-center justify-center" style={{ height: d }}>
{NEWTON_BALLS.map((i) => {
const move = moves[i];
return (
<motion.span
key={i}
className="rounded-full bg-current"
style={{ width: d, height: d }}
animate={reduce || !move ? undefined : { x: move.x }}
transition={
reduce || !move
? undefined
: {
duration: speed * 1.5,
ease: EASE_IN_OUT,
repeat: Infinity,
times: move.times,
}
}
/>
);
})}
</span>
);
}
function Helix({ size, speed, reduce }: PartProps) {
const rows = 7;
const dot = size * 0.14;
const amp = size * 0.32;
return (
<span className="relative" style={{ width: size, height: size }}>
{Array.from({ length: rows }, (_, r) => {
const top = (r / (rows - 1)) * (size - dot);
const delay = (r / rows) * speed;
return (
<span key={`row-${top}`}>
<motion.span
className="absolute rounded-full bg-current"
style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}
animate={
reduce
? { opacity: [0.4, 1, 0.4] }
: {
x: [amp, -amp, amp],
scale: [1, 0.5, 1],
opacity: [1, 0.45, 1],
}
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay,
}}
/>
<motion.span
className="absolute rounded-full bg-current"
style={{ width: dot, height: dot, left: size / 2 - dot / 2, top }}
animate={
reduce
? { opacity: [0.4, 1, 0.4] }
: {
x: [-amp, amp, -amp],
scale: [0.5, 1, 0.5],
opacity: [0.45, 1, 0.45],
}
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay,
}}
/>
</span>
);
})}
</span>
);
}
function Percent({ size, speed, reduce }: PartProps) {
const [p, setP] = useState(0);
useEffect(() => {
const dur = (reduce ? speed * 2 : speed) * 1000;
const start = { t: 0 };
const tickMs = 40;
const id = setInterval(() => {
start.t += tickMs;
const next = Math.min(100, Math.round((start.t / dur) * 100));
setP(next);
if (next >= 100) start.t = 0;
}, tickMs);
return () => clearInterval(id);
}, [speed, reduce]);
return (
<span
className="flex flex-col items-center"
style={{ gap: size * 0.14, width: size * 1.4 }}
>
<span
className="font-mono font-medium tabular-nums"
style={{ fontSize: size * 0.42, lineHeight: 1 }}
>
{p}%
</span>
<span
className="w-full overflow-hidden rounded-full bg-current/15"
style={{ height: Math.max(3, size * 0.1) }}
>
<span
className="block h-full rounded-full bg-current"
style={{ width: `${p}%` }}
/>
</span>
</span>
);
}
function Bars({ size, speed, reduce }: PartProps) {
const bar = size * 0.16;
return (
<span className="flex items-center" style={{ gap: size * 0.1, height: size }}>
{[0, 1, 2, 3].map((i) => (
<motion.span
key={i}
className="rounded-full bg-current"
style={{ width: bar, height: size, originY: 1 }}
animate={
reduce ? { opacity: [0.4, 1, 0.4] } : { scaleY: [0.3, 1, 0.3] }
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: i * speed * 0.12,
}}
/>
))}
</span>
);
}
function DotMatrix({ size, speed, reduce }: PartProps) {
const n = 3;
const gap = size * 0.14;
const dot = (size - gap * (n - 1)) / n;
const cells = Array.from({ length: n * n }, (_, idx) => idx);
return (
<span
className="grid"
style={{
gap,
gridTemplateColumns: `repeat(${n}, ${dot}px)`,
}}
>
{cells.map((idx) => {
const x = idx % n;
const y = Math.floor(idx / n);
// Diagonal wave: cells light in order of their distance from the corner.
const delay = ((x + y) / (2 * (n - 1))) * speed;
return (
<motion.span
key={idx}
className="rounded-full bg-current"
style={{ width: dot, height: dot }}
animate={
reduce
? { opacity: [0.3, 1, 0.3] }
: { opacity: [0.2, 1, 0.2], scale: [0.7, 1, 0.7] }
}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay,
}}
/>
);
})}
</span>
);
}
// Ordered Bayer 4x4 matrix — the classic dithering threshold pattern. Cells
// light in this order, so the fill shimmers like a dissolving halftone.
const BAYER_4 = [
0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,
];
function Dither({ size, speed, reduce }: PartProps) {
const n = 4;
const gap = Math.max(1, size * 0.05);
const cell = (size - gap * (n - 1)) / n;
return (
<span
className="grid"
style={{ gap, gridTemplateColumns: `repeat(${n}, ${cell}px)` }}
>
{BAYER_4.map((order, idx) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: fixed matrix cells, order never changes
key={idx}
className="bg-current"
style={{ width: cell, height: cell }}
animate={reduce ? { opacity: [0.3, 1, 0.3] } : { opacity: [0.1, 1, 0.1] }}
transition={{
duration: speed,
ease: EASE_IN_OUT,
repeat: Infinity,
delay: (order / BAYER_4.length) * speed,
}}
/>
))}
</span>
);
}
@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
conversations?Conversation[]—defaultConversations?Conversation[][]onConversationsChange?((conversations: Conversation[]) => void)—activeId?string | null—defaultActiveId?string | nullnullonActiveIdChange?((id: string) => void)—onRename?((id: string, title: string) => unknown)Called after a rename commits; return a promise to keep the editor's spinner until it settles.
—onPinChange?((id: string, pinned: boolean) => void)—onDelete?((id: string) => void)—onNewChat?(() => void)Renders the "New chat" button. The parent inserts the conversation (usually `streaming`).
—now?DateOverride "today" (otherwise read after mount so server and client markup match).
—searchable?booleantruelabels?Partial<Record<ConversationGroup, string>>—maxHeight?numberMax height of the scrolling list. Default 320.
320emptyState?ReactNode—className?string—Updated