Artifact Panel
Side canvas that slides open beside a chat (its width eases so the conversation reflows smoothly) to show a generated artifact, with a version switcher whose label rolls by direction, Preview/Code tabs rendering code through CodeBlock, copy, download and close actions, a shared-layout expand into a fullscreen overlay, cross-fading content between versions and a skeleton while a new version generates.
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 shiki 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;
import type { CSSProperties } from "react";
export const TEXT_SHIMMER_KEYFRAMES =
"@keyframes karaui-text-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}";
export const TEXT_SHIMMER_CLASS_NAME =
"bg-[length:200%_100%] bg-clip-text text-transparent bg-[linear-gradient(110deg,var(--muted-foreground)_30%,var(--foreground)_50%,var(--muted-foreground)_70%)]";
export function textShimmerStyle(duration: number): CSSProperties {
return {
animation: `karaui-text-shimmer ${duration}s linear infinite`,
};
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/agents/artifact-panel
import {
ChevronLeft,
ChevronRight,
Code2,
Download,
Eye,
Maximize2,
Minimize2,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react";
import {
type ReactNode,
type Ref,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import type { AgentCodeLanguage } from "@/components/agents/agent-code";
import { CodeBlock } from "@/components/agents/code-block";
import { CopyButton } from "@/components/motion/copy-button";
import { Skeleton, SkeletonText } from "@/components/motion/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/motion/tabs";
import { TextShimmer } from "@/components/motion/text-shimmer";
import { EASE_DRAWER, EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface ArtifactVersion {
id: string;
/** Short label shown under the title ("Added dark mode"). */
label?: string;
code: string;
language?: AgentCodeLanguage;
/** Rendered output for the Preview tab. Without it the panel shows code only. */
preview?: ReactNode;
}
export type ArtifactTab = "code" | "preview";
export interface ArtifactPanelProps {
title: ReactNode;
versions: ArtifactVersion[];
/** Used by the code header and the default download. */
filename?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Selected version id; defaults to the latest. */
version?: string;
defaultVersion?: string;
onVersionChange?: (id: string) => void;
tab?: ArtifactTab;
defaultTab?: ArtifactTab;
onTabChange?: (tab: ArtifactTab) => void;
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
/** A new version is being written: shows a skeleton in place of the content. */
generating?: boolean;
/** Replace the default download (a Blob of the code saved as `filename`). */
onDownload?: (version: ArtifactVersion) => void;
/** Panel width in px when docked beside the chat. Default 400. */
width?: number;
/** The chat the panel slides in next to. Omit to render the panel alone. */
children?: ReactNode;
className?: string;
panelClassName?: string;
}
function useControllable<T>(
value: T | undefined,
defaultValue: T,
onChange: ((next: T) => void) | undefined,
) {
const [internal, setInternal] = useState(defaultValue);
const controlled = value !== undefined;
const current = controlled ? value : internal;
const set = useCallback(
(next: T) => {
if (!controlled) setInternal(next);
onChange?.(next);
},
[controlled, onChange],
);
return [current, set] as const;
}
function downloadText(text: string, filename: string) {
const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function ArtifactPanel({
title,
versions,
filename = "artifact.tsx",
open: openProp,
defaultOpen = true,
onOpenChange,
version: versionProp,
defaultVersion,
onVersionChange,
tab: tabProp,
defaultTab = "preview",
onTabChange,
expanded: expandedProp,
defaultExpanded = false,
onExpandedChange,
generating = false,
onDownload,
width = 400,
children,
className,
panelClassName,
}: ArtifactPanelProps) {
const uid = useId();
const reduce = useReducedMotion() ?? false;
const [open, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);
const [expanded, setExpanded] = useControllable(expandedProp, defaultExpanded, onExpandedChange);
const [tab, setTab] = useControllable<ArtifactTab>(tabProp, defaultTab, onTabChange);
const latest = versions[versions.length - 1]?.id ?? "";
const [versionId, setVersionId] = useControllable<string>(
versionProp,
defaultVersion ?? "",
onVersionChange,
);
// Uncontrolled with no default follows the newest version as they arrive.
const index = Math.max(
0,
versions.findIndex((v) => v.id === (versionId || latest)),
);
const current = versions[index];
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// Escape leaves fullscreen.
useEffect(() => {
if (!expanded) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
focusToggle.current = true;
setExpanded(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [expanded, setExpanded]);
// Docked and fullscreen surfaces mount fresh; after a user toggle the new
// one takes focus on its expand/minimize button.
const focusToggle = useRef(false);
const toggleExpanded = (next: boolean) => {
focusToggle.current = true;
setExpanded(next);
};
const effectiveTab: ArtifactTab = current?.preview == null ? "code" : tab;
const body = current ? (
<PanelBody
uid={uid}
title={title}
filename={filename}
versions={versions}
index={index}
current={current}
tab={effectiveTab}
onTab={setTab}
onVersion={(i) => {
const next = versions[i];
if (next) setVersionId(next.id);
}}
generating={generating}
expanded={expanded}
onExpand={toggleExpanded}
focusRef={focusToggle}
onClose={() => {
setExpanded(false);
setOpen(false);
}}
onDownload={() =>
onDownload ? onDownload(current) : downloadText(current.code, filename)
}
reduce={reduce}
/>
) : null;
const surface = cn(
"flex h-full flex-col overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-sm",
panelClassName,
);
return (
<div className={cn("relative flex h-full w-full min-w-0", className)}>
{children != null && <div className="min-w-0 flex-1">{children}</div>}
<AnimatePresence initial={false}>
{open && (
// Width is animated on purpose: the docked slot opening is what
// pushes the chat aside, so the chat reflows smoothly instead of
// jumping. The panel inside keeps a fixed width and never reflows.
<motion.div
key="slot"
className={cn("h-full shrink-0 overflow-hidden", children == null && "w-full")}
initial={children == null || reduce ? false : { width: 0 }}
animate={children == null ? undefined : { width: width + 12 }}
exit={children == null || reduce ? { opacity: 0 } : { width: 0 }}
transition={{ duration: 0.32, ease: EASE_DRAWER }}
>
<div
className={cn("h-full", children != null && "pl-3")}
style={children != null ? { width: width + 12 } : undefined}
>
{expanded ? (
<div className="flex h-full items-center justify-center rounded-2xl border border-dashed border-border-strong text-xs text-muted-foreground">
Open in fullscreen
</div>
) : (
<motion.aside
layoutId={reduce ? undefined : `${uid}-artifact`}
aria-label="Artifact"
initial={reduce ? { opacity: 0 } : { opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, x: 24 }}
transition={SPRING_PANEL}
className={surface}
>
{body}
</motion.aside>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{mounted &&
createPortal(
<AnimatePresence>
{open && expanded && (
<motion.div key="fullscreen" className="fixed inset-0 z-50 p-4 sm:p-8">
<motion.button
type="button"
aria-label="Exit fullscreen"
tabIndex={-1}
className="absolute inset-0 cursor-default bg-background/70 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
onClick={() => toggleExpanded(false)}
/>
{/* Shares a layoutId with the docked panel, so the same surface
grows out of the chat instead of a new one appearing. */}
<motion.div
layoutId={reduce ? undefined : `${uid}-artifact`}
aria-label="Artifact"
aria-modal="true"
role="dialog"
initial={reduce ? { opacity: 0 } : undefined}
animate={{ opacity: 1 }}
exit={reduce ? { opacity: 0 } : undefined}
transition={SPRING_LAYOUT}
className={cn(surface, "relative mx-auto max-w-5xl shadow-lg")}
>
{body}
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</div>
);
}
const LABEL_ROLL: Variants = {
enter: (d: number) => ({ opacity: 0, y: d * 10 }),
center: { opacity: 1, y: 0 },
exit: (d: number) => ({ opacity: 0, y: d * -10 }),
};
const LABEL_FADE: Variants = { enter: { opacity: 0 }, center: { opacity: 1 }, exit: { opacity: 0 } };
interface PanelBodyProps {
uid: string;
title: ReactNode;
filename: string;
versions: ArtifactVersion[];
index: number;
current: ArtifactVersion;
tab: ArtifactTab;
onTab: (tab: ArtifactTab) => void;
onVersion: (index: number) => void;
generating: boolean;
expanded: boolean;
onExpand: (expanded: boolean) => void;
onClose: () => void;
onDownload: () => void;
focusRef: { current: boolean };
reduce: boolean;
}
function PanelBody({
uid,
title,
filename,
versions,
index,
current,
tab,
onTab,
onVersion,
generating,
expanded,
onExpand,
onClose,
onDownload,
focusRef,
reduce,
}: PanelBodyProps) {
const expandRef = useRef<HTMLButtonElement>(null);
const prevIndex = useRef(index);
const direction = index >= prevIndex.current ? 1 : -1;
useEffect(() => {
prevIndex.current = index;
}, [index]);
useEffect(() => {
if (!focusRef.current) return;
focusRef.current = false;
expandRef.current?.focus();
}, [focusRef]);
const hasPreview = current.preview != null;
const contentKey = generating ? "generating" : `${current.id}:${tab}`;
return (
<>
<header className="flex items-start gap-2 border-b border-border px-4 py-3">
<div className="min-w-0 flex-1">
<h2 id={`${uid}-title`} className="truncate text-sm font-semibold text-foreground">
{title}
</h2>
<div className="relative h-4 overflow-hidden text-xs text-muted-foreground">
<AnimatePresence initial={false} mode="popLayout" custom={direction}>
<motion.p
key={generating ? "gen" : current.id}
custom={direction}
variants={reduce ? LABEL_FADE : LABEL_ROLL}
initial="enter"
animate="center"
exit="exit"
transition={SPRING_SWAP}
className="truncate"
>
{generating ? (
<TextShimmer>Generating new version…</TextShimmer>
) : (
(current.label ?? `Version ${index + 1}`)
)}
</motion.p>
</AnimatePresence>
</div>
</div>
{versions.length > 1 && (
<fieldset
aria-label="Version"
className="flex shrink-0 items-center rounded-lg border border-border bg-muted/50"
>
<IconButton
label="Previous version"
disabled={index === 0 || generating}
onClick={() => onVersion(index - 1)}
>
<ChevronLeft />
</IconButton>
<span
aria-live="polite"
className="min-w-11 px-0.5 text-center font-mono text-[11px] tabular-nums text-muted-foreground"
>
v{index + 1}
<span className="text-muted-foreground/60">/{versions.length}</span>
</span>
<IconButton
label="Next version"
disabled={index === versions.length - 1 || generating}
onClick={() => onVersion(index + 1)}
>
<ChevronRight />
</IconButton>
</fieldset>
)}
<IconButton label="Close artifact" onClick={onClose}>
<X />
</IconButton>
</header>
<div className="flex items-center gap-1 px-3 py-2">
{hasPreview ? (
<Tabs
value={tab}
onValueChange={(v) => onTab(v as ArtifactTab)}
variant="segment"
>
<TabsList className="bg-muted">
<TabsTrigger value="preview" className="gap-1.5 px-2.5 py-1 text-xs">
<Eye aria-hidden="true" className="size-3.5" />
Preview
</TabsTrigger>
<TabsTrigger value="code" className="gap-1.5 px-2.5 py-1 text-xs">
<Code2 aria-hidden="true" className="size-3.5" />
Code
</TabsTrigger>
</TabsList>
</Tabs>
) : (
<span className="px-1 font-mono text-xs text-muted-foreground">{filename}</span>
)}
<div className="ml-auto flex items-center gap-0.5">
<CopyButton
value={current.code}
size="icon"
variant="ghost"
showLabel={false}
disabled={generating}
aria-label="Copy code"
/>
<IconButton label="Download" disabled={generating} onClick={onDownload}>
<Download />
</IconButton>
<IconButton
ref={expandRef}
label={expanded ? "Exit fullscreen" : "Expand to fullscreen"}
onClick={() => onExpand(!expanded)}
>
{expanded ? <Minimize2 /> : <Maximize2 />}
</IconButton>
</div>
</div>
<div className="relative grid min-h-0 flex-1 px-3 pb-3">
<AnimatePresence initial={false}>
<motion.div
key={contentKey}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.985 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, transition: { duration: 0.14, ease: EASE_OUT } }}
transition={{ duration: 0.24, ease: EASE_OUT }}
className="min-h-0 overflow-auto rounded-xl [grid-area:1/1] [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]"
>
{generating ? (
<div aria-busy="true" className="flex h-full flex-col gap-3 rounded-xl bg-muted/60 p-4">
<span className="sr-only">Generating a new version</span>
{tab === "preview" && hasPreview ? (
<>
<Skeleton height={28} width="45%" />
<Skeleton height={96} />
<SkeletonText lines={3} />
</>
) : (
<SkeletonText lines={9} lastLineWidth="35%" />
)}
</div>
) : tab === "preview" && hasPreview ? (
<div className="flex min-h-full items-center justify-center rounded-xl border border-border bg-background p-4">
{current.preview}
</div>
) : (
<CodeBlock
code={current.code}
language={current.language ?? "tsx"}
filename={filename}
copyable={false}
maxHeight={expanded ? 2000 : 1000}
className="rounded-xl"
/>
)}
</motion.div>
</AnimatePresence>
</div>
</>
);
}
const IconButton = ({
label,
disabled,
onClick,
children,
ref,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: ReactNode;
ref?: Ref<HTMLButtonElement>;
}) => (
<motion.button
ref={ref}
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
whileTap={disabled ? undefined : { scale: 0.9 }}
transition={SPRING_SWAP}
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors [&_svg]:size-3.5",
"hover:bg-foreground/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-40",
)}
>
{children}
</motion.button>
);
"use client";
import {
type CSSProperties,
Fragment,
useEffect,
useState,
} from "react";
import { createHighlighter, type Highlighter } from "shiki";
import { cn } from "@/lib/utils";
export type AgentCodeLanguage =
| "bash"
| "diff"
| "json"
| "text"
| "tsx"
| "typescript";
export interface AgentCodeToken {
content: string;
offset: number;
light?: string;
dark?: string;
}
export type AgentCodeTokenLines = AgentCodeToken[][];
export interface AgentCodeProps {
code: string;
language?: AgentCodeLanguage;
className?: string;
}
export interface AgentCodeLineProps {
code: string;
tokens?: AgentCodeToken[];
className?: string;
}
const LIGHT_THEME = "github-light-high-contrast";
const DARK_THEME = "github-dark-high-contrast";
let agentCodeHighlighter: Promise<Highlighter> | null = null;
const tokenCache = new Map<string, AgentCodeTokenLines>();
function getAgentCodeHighlighter() {
if (!agentCodeHighlighter) {
agentCodeHighlighter = createHighlighter({
themes: [LIGHT_THEME, DARK_THEME],
langs: ["bash", "diff", "json", "tsx", "typescript"],
});
}
return agentCodeHighlighter;
}
function tokenCacheKey(code: string, language: AgentCodeLanguage) {
return `${language}\u0000${code}`;
}
export function useAgentCodeTokens(
code: string,
language: AgentCodeLanguage,
) {
const key = tokenCacheKey(code, language);
const cached = tokenCache.get(key);
const [result, setResult] = useState<{
key: string;
code: string;
language: AgentCodeLanguage;
lines: AgentCodeTokenLines;
} | null>(cached ? { key, code, language, lines: cached } : null);
useEffect(() => {
const current = tokenCache.get(key);
if (current) {
setResult({ key, code, language, lines: current });
return;
}
let cancelled = false;
getAgentCodeHighlighter().then((highlighter) => {
if (cancelled) return;
const lines = highlighter
.codeToTokensWithThemes(code, {
lang: language,
themes: {
light: LIGHT_THEME,
dark: DARK_THEME,
},
})
.map((line) =>
line.map((token) => ({
content: token.content,
offset: token.offset,
light: token.variants.light?.color,
dark: token.variants.dark?.color,
})),
);
tokenCache.set(key, lines);
setResult({ key, code, language, lines });
});
return () => {
cancelled = true;
};
}, [code, key, language]);
if (result?.key === key) return result.lines;
if (result?.language === language && code.startsWith(result.code)) {
return result.lines;
}
return null;
}
export function AgentCodeLine({
code,
tokens,
className,
}: AgentCodeLineProps) {
return (
<span className={className}>
{tokens
? tokens.map((token) => (
<span
key={`${token.offset}-${token.content}`}
style={
{
"--agent-code-light": token.light ?? "currentColor",
"--agent-code-dark": token.dark ?? token.light ?? "currentColor",
} as CSSProperties
}
className="text-[var(--agent-code-light)] dark:text-[var(--agent-code-dark)]"
>
{token.content}
</span>
))
: code}
</span>
);
}
export function AgentCode({
code,
language = "bash",
className,
}: AgentCodeProps) {
const tokens = useAgentCodeTokens(code, language);
let offset = 0;
const lines = code.split("\n").map((content) => {
const line = { content, offset };
offset += content.length + 1;
return line;
});
return (
<pre
className={cn(
"m-0 overflow-x-auto whitespace-pre font-mono text-xs leading-5 text-foreground/85",
className,
)}
>
<code>
{lines.map((line, index) => (
<Fragment key={line.offset}>
<AgentCodeLine code={line.content} tokens={tokens?.[index]} />
{index < lines.length - 1 ? "\n" : null}
</Fragment>
))}
</code>
</pre>
);
}
"use client";
import { Check, Copy, FileCode2, LoaderCircle } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
type AgentCodeLanguage,
AgentCodeLine,
useAgentCodeTokens,
} from "@/components/agents/agent-code";
import { SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type CodeBlockStatus = "streaming" | "complete";
export interface CodeBlockProps {
code: string;
language?: AgentCodeLanguage;
filename?: ReactNode;
status?: CodeBlockStatus;
showLineNumbers?: boolean;
highlightLines?: number[];
maxHeight?: number;
wrap?: boolean;
copyable?: boolean;
onCopy?: () => void | Promise<void>;
className?: string;
}
export function CodeBlock({
code,
language = "typescript",
filename,
status = "complete",
showLineNumbers = true,
highlightLines = [],
maxHeight = 280,
wrap = false,
copyable = true,
onCopy,
className,
}: CodeBlockProps) {
const reduce = useReducedMotion() ?? false;
const viewportRef = useRef<HTMLDivElement>(null);
const copyTimer = useRef<number | undefined>(undefined);
const [copied, setCopied] = useState(false);
const streaming = status === "streaming";
const tokens = useAgentCodeTokens(code, language);
const highlighted = useMemo(
() => new Set(highlightLines),
[highlightLines],
);
let offset = 0;
const lines = code.split("\n").map((content) => {
const line = { content, offset };
offset += content.length + 1;
return line;
});
useEffect(
() => () => {
if (copyTimer.current) window.clearTimeout(copyTimer.current);
},
[],
);
useLayoutEffect(() => {
const viewport = viewportRef.current;
if (!viewport || !streaming) return;
const frame = requestAnimationFrame(() => {
if (viewport.scrollHeight <= viewport.clientHeight) return;
if (typeof viewport.scrollTo === "function") {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: reduce ? "auto" : "smooth",
});
} else {
viewport.scrollTop = viewport.scrollHeight;
}
});
return () => cancelAnimationFrame(frame);
});
const handleCopy = useCallback(async () => {
if (onCopy) await onCopy();
else await navigator.clipboard?.writeText(code);
setCopied(true);
if (copyTimer.current) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1600);
}, [code, onCopy]);
return (
<div
data-state={status}
aria-busy={streaming}
className={cn(
"w-full overflow-hidden rounded-2xl bg-muted/80 text-sm",
className,
)}
>
<div className="flex h-10 items-center gap-2.5 px-3">
<FileCode2
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground/70"
/>
{filename ? (
<span className="min-w-0 truncate font-mono text-xs text-foreground/80">
{filename}
</span>
) : null}
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/55">
{language}
</span>
<span
className={cn(
"ml-auto inline-flex shrink-0 items-center gap-1 text-[10px] font-medium",
streaming
? "text-blue-600 dark:text-blue-400"
: "text-emerald-600 dark:text-emerald-400",
)}
>
{streaming ? (
<LoaderCircle className={cn("size-3", !reduce && "animate-spin")} />
) : (
<Check className="size-3" />
)}
{streaming ? "Writing" : "Ready"}
</span>
{copyable || onCopy ? (
<motion.button
type="button"
aria-label={copied ? "Copied" : "Copy code"}
title={copied ? "Copied" : "Copy code"}
onClick={handleCopy}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
className="grid size-7 shrink-0 place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-background/70 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</motion.button>
) : null}
</div>
<div
ref={viewportRef}
role={streaming ? "log" : undefined}
aria-live={streaming ? "polite" : undefined}
className="scrollbar-hide overflow-auto border-t border-foreground/[0.06] py-2"
style={{ maxHeight }}
>
<pre className="m-0 min-w-max font-mono text-xs leading-5 text-foreground/85">
<code>
{lines.map((line, index) => {
const lineNumber = index + 1;
return (
<span
key={line.offset}
className={cn(
"grid min-h-5",
showLineNumbers
? "grid-cols-[2.75rem_minmax(0,1fr)]"
: "grid-cols-1",
highlighted.has(lineNumber) && "bg-blue-500/[0.07]",
)}
>
{showLineNumbers ? (
<span className="select-none pr-3 text-right tabular-nums text-muted-foreground/35">
{lineNumber}
</span>
) : null}
<AgentCodeLine
code={line.content}
tokens={tokens?.[index]}
className={cn(
"pr-4",
showLineNumbers ? "pl-1" : "pl-4",
wrap ? "whitespace-pre-wrap break-words" : "whitespace-pre",
)}
/>
</span>
);
})}
</code>
</pre>
</div>
</div>
);
}
"use client";
import { Check, Copy, X } from "lucide-react";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
export type CopyButtonStatus = "idle" | "copied" | "error";
export type CopyButtonVariant = "secondary" | "ghost" | "outline" | "primary";
export type CopyButtonSize = "sm" | "md" | "icon";
export interface CopyButtonLabels {
idle: string;
copied: string;
error: string;
}
export interface CopyButtonProps
extends Omit<
HTMLMotionProps<"button">,
"value" | "children" | "onCopy" | "onError" | "onClick"
> {
/** Text to copy, or a (possibly async) getter evaluated on click. */
value: string | (() => string | Promise<string>);
/** Show a text label next to the icon that swaps with the status. */
showLabel?: boolean;
/** Label text per status. Also used for the accessible name and announcement. */
labels?: Partial<CopyButtonLabels>;
/** How long the copied/error state lingers before resetting, in ms. */
timeout?: number;
variant?: CopyButtonVariant;
size?: CopyButtonSize;
/** Override the idle / copied / error icons. */
icons?: Partial<Record<CopyButtonStatus, ReactNode>>;
onCopy?: (text: string) => void;
onCopyError?: (error: unknown) => void;
onStatusChange?: (status: CopyButtonStatus) => void;
}
const DEFAULT_LABELS: CopyButtonLabels = {
idle: "Copy",
copied: "Copied",
error: "Failed",
};
const VARIANT_CLASS: Record<CopyButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary:
"border border-border bg-card text-muted-foreground hover:text-foreground",
ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground",
outline:
"border border-border bg-transparent text-muted-foreground hover:bg-primary/5 hover:text-foreground",
};
const SIZE_CLASS: Record<CopyButtonSize, string> = {
sm: "h-8 gap-1.5 rounded-full px-3 text-xs",
md: "h-10 gap-2 rounded-full px-4 text-sm",
icon: "size-8 rounded-lg",
};
const SWAP_BLUR = "blur(6px)";
const ICON_VARIANTS: Variants = {
initial: { opacity: 0, scale: 0.3, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
scale: 0.3,
filter: SWAP_BLUR,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
const LABEL_VARIANTS: Variants = {
initial: { opacity: 0, y: "60%", filter: SWAP_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
y: "-60%",
filter: SWAP_BLUR,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
const FADE_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.15 } },
exit: { opacity: 0, transition: { duration: 0.1 } },
};
/** `document.execCommand("copy")` fallback for insecure contexts and old browsers. */
function legacyCopy(text: string) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
document.body.appendChild(textarea);
const selection = document.getSelection();
const previous =
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
textarea.select();
let ok = false;
try {
ok = document.execCommand("copy");
} finally {
document.body.removeChild(textarea);
if (previous && selection) {
selection.removeAllRanges();
selection.addRange(previous);
}
}
if (!ok) throw new Error("Copy command was rejected");
}
/** Copy text with the async Clipboard API, falling back to execCommand. */
export async function copyToClipboard(text: string) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Permission denied or insecure context — try the legacy path.
}
}
legacyCopy(text);
}
export function CopyButton({
value,
showLabel = false,
labels,
timeout = 1600,
variant = "secondary",
size,
icons,
onCopy,
onCopyError,
onStatusChange,
className,
disabled,
type = "button",
...props
}: CopyButtonProps) {
const reduce = useReducedMotion() ?? false;
const [status, setStatus] = useState<CopyButtonStatus>("idle");
const timer = useRef<number | undefined>(undefined);
const text = { ...DEFAULT_LABELS, ...labels };
const resolvedSize = size ?? (showLabel ? "sm" : "icon");
useEffect(
() => () => {
if (timer.current) window.clearTimeout(timer.current);
},
[],
);
const update = useCallback(
(next: CopyButtonStatus) => {
setStatus(next);
onStatusChange?.(next);
},
[onStatusChange],
);
const handleClick = async () => {
if (timer.current) window.clearTimeout(timer.current);
try {
const resolved = typeof value === "function" ? await value() : value;
await copyToClipboard(resolved);
update("copied");
onCopy?.(resolved);
} catch (error) {
update("error");
onCopyError?.(error);
}
timer.current = window.setTimeout(() => update("idle"), timeout);
};
const iconNode: Record<CopyButtonStatus, ReactNode> = {
idle: icons?.idle ?? <Copy />,
copied: icons?.copied ?? <Check className="text-(--color-success)" />,
error: icons?.error ?? <X className="text-destructive" />,
};
const iconVariants = reduce ? FADE_VARIANTS : ICON_VARIANTS;
const labelVariants = reduce ? FADE_VARIANTS : LABEL_VARIANTS;
return (
<>
<motion.button
type={type}
disabled={disabled}
onClick={handleClick}
whileTap={reduce || disabled ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
aria-label={showLabel ? undefined : text[status]}
data-status={status}
className={cn(
"relative inline-flex shrink-0 select-none items-center justify-center font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
TOUCH_TARGET_CLASS,
VARIANT_CLASS[variant],
SIZE_CLASS[resolvedSize],
className,
)}
{...props}
>
<span aria-hidden="true" className="relative grid size-3.5 place-items-center [&_svg]:size-3.5">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={status}
variants={iconVariants}
initial="initial"
animate="animate"
exit="exit"
className="col-start-1 row-start-1 grid place-items-center"
>
{iconNode[status]}
</motion.span>
</AnimatePresence>
</span>
{showLabel ? (
<span className="relative grid overflow-hidden">
{/* Every label sits invisibly in the same cell so the button keeps
the width of the longest one and never reflows mid-swap. */}
{(Object.keys(text) as CopyButtonStatus[]).map((key) => (
<span
key={key}
aria-hidden="true"
className="invisible col-start-1 row-start-1 whitespace-nowrap"
>
{text[key]}
</span>
))}
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={status}
variants={labelVariants}
initial="initial"
animate="animate"
exit="exit"
className="col-start-1 row-start-1 whitespace-nowrap text-left"
>
{text[status]}
</motion.span>
</AnimatePresence>
</span>
) : null}
</motion.button>
{/* Outside the button so the announcement never joins its accessible name. */}
<span aria-live="polite" className="sr-only">
{status === "copied"
? `${text.copied} to clipboard`
: status === "error"
? `${text.error} to copy`
: ""}
</span>
</>
);
}
"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, MotionConfig, useReducedMotion, type Transition } from "motion/react";
import {
createContext,
useCallback,
useContext,
useId,
useMemo,
useState,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
type Variant = "pill" | "underline" | "segment";
type Ctx = {
value: string;
setValue: (v: string) => void;
layoutId: string;
variant: Variant;
};
const TabsCtx = createContext<Ctx | null>(null);
function useTabs() {
const ctx = useContext(TabsCtx);
if (!ctx) throw new Error("Tabs.* must be used inside <Tabs>");
return ctx;
}
// Weighty spring for the active-tab indicator: a touch of overshoot so it
// settles with life instead of snapping.
const transition: Transition = {
type: "spring",
stiffness: 170,
damping: 24,
mass: 1.2,
};
export function Tabs({
defaultValue,
value,
onValueChange,
variant = "pill",
children,
className,
}: {
defaultValue?: string;
value?: string;
onValueChange?: (v: string) => void;
variant?: Variant;
children: ReactNode;
className?: string;
}) {
const [internal, setInternal] = useState(defaultValue ?? "");
const layoutId = useId();
const reduce = useReducedMotion();
const controlled = value !== undefined;
const current = controlled ? value : internal;
const setValue = useCallback(
(v: string) => {
if (!controlled) setInternal(v);
onValueChange?.(v);
},
[controlled, onValueChange],
);
const contextValue = useMemo(
() => ({ value: current, setValue, layoutId, variant }),
[current, layoutId, setValue, variant],
);
return (
<MotionConfig transition={reduce ? { duration: 0 } : transition}>
<TabsCtx.Provider value={contextValue}>
{/* layoutRoot: the indicator's layoutId measures in page coordinates, so
inside fixed/scrolled containers it would replay scroll offsets as
movement. The pill only ever travels within the list, so scoping
projection to the Tabs wrapper is always correct. */}
<motion.div layoutRoot className={className}>
{children}
</motion.div>
</TabsCtx.Provider>
</MotionConfig>
);
}
const listClasses: Record<Variant, string> = {
pill: "inline-flex items-center gap-1 rounded-full bg-card p-1",
underline: "inline-flex items-center gap-1 border-b border-border",
segment: "inline-flex items-center gap-0 rounded-lg bg-card p-0.5",
};
export function TabsList({ children, className }: { children: ReactNode; className?: string }) {
const { variant } = useTabs();
return (
<div role="tablist" className={cn(listClasses[variant], className)}>
{children}
</div>
);
}
export function TabsTrigger({
value,
children,
className,
indicatorClassName,
}: {
value: string;
children: ReactNode;
className?: string;
indicatorClassName?: string;
}) {
const { value: current, setValue, layoutId, variant } = useTabs();
const active = current === value;
if (variant === "underline") {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={() => setValue(value)}
className={cn(
"relative isolate px-3 pb-2.5 pt-1 -mb-px text-sm font-medium transition-colors min-h-[44px] inline-flex items-center",
active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
className,
)}
>
{children}
{active ? (
<motion.span
layoutId={layoutId}
layout="position"
className={cn(
"absolute -bottom-px left-0 right-0 h-px bg-primary",
indicatorClassName,
)}
/>
) : null}
</button>
);
}
const radius = variant === "pill" ? "rounded-full" : "rounded-md";
return (
<div className="relative">
{active ? (
<motion.span
layoutId={layoutId}
layout="position"
style={{ borderRadius: variant === "pill" ? 9999 : 8 }}
className={cn(
"absolute inset-0 bg-primary",
radius,
indicatorClassName,
)}
/>
) : null}
<button
type="button"
role="tab"
aria-selected={active}
onClick={() => setValue(value)}
className={cn(
"relative z-10 inline-flex items-center justify-center whitespace-nowrap bg-transparent px-3.5 py-1.5 text-sm font-medium outline-none",
TOUCH_TARGET_CLASS,
"transition-colors",
active
? "text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
radius,
className,
)}
>
{children}
</button>
</div>
);
}
export function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {
const { value: current } = useTabs();
const reduce = useReducedMotion();
const active = current === value;
// Inactive panels stay mounted but hidden, so their content (e.g. source
// code) is present in the server-rendered HTML for crawlers and assistive
// tech, instead of being dropped from the DOM.
if (!active) {
return (
<div hidden className={className}>
{children}
</div>
);
}
return (
<motion.div
key={value}
initial={{ opacity: 0, y: reduce ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
className={cn("mt-4", className)}
>
{children}
</motion.div>
);
}
import { cn } from "@/lib/utils";
import type { ElementType, ReactNode } from "react";
import {
TEXT_SHIMMER_CLASS_NAME,
TEXT_SHIMMER_KEYFRAMES,
textShimmerStyle,
} from "@/lib/text-shimmer";
export interface TextShimmerProps {
children: ReactNode;
as?: ElementType;
duration?: number;
className?: string;
}
export function TextShimmer({ children, as: Comp = "span", duration = 2.5, className }: TextShimmerProps) {
return (
<>
<style>
{TEXT_SHIMMER_KEYFRAMES}
</style>
<Comp
style={textShimmerStyle(duration)}
className={cn(
"inline-block",
TEXT_SHIMMER_CLASS_NAME,
className,
)}
>
{children}
</Comp>
</>
);
}
@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
titleReactNode—versionsArtifactVersion[]—filename?stringUsed by the code header and the default download.
artifact.tsxopen?boolean—defaultOpen?booleantrueonOpenChange?((open: boolean) => void)—version?stringSelected version id; defaults to the latest.
—defaultVersion?string—onVersionChange?((id: string) => void)—tab?"code" | "preview"—defaultTab?"code" | "preview"previewonTabChange?((tab: ArtifactTab) => void)—expanded?boolean—defaultExpanded?booleanfalseonExpandedChange?((expanded: boolean) => void)—generating?booleanA new version is being written: shows a skeleton in place of the content.
falseonDownload?((version: ArtifactVersion) => void)Replace the default download (a Blob of the code saved as `filename`).
—width?numberPanel width in px when docked beside the chat. Default 400.
400children?ReactNodeThe chat the panel slides in next to. Omit to render the panel alone.
—className?string—panelClassName?string—Updated