File and image cards for uploads, with progress, states, actions, a full-card trigger and names that keep their extension.
import { IconFileText, IconX } from "@tabler/icons-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
export function AttachmentDemo() {
return (
<Attachment>
<AttachmentMedia>
<IconFileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove sales-dashboard.pdf">
<IconX />
</AttachmentAction>
</AttachmentActions>
</Attachment>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/attachment.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/attachment.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { IconFile } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { duration, easeOut, easeSpring } from "@/lib/motion" import { AspectRatio } from "@/components/ui/aspect-ratio" import { Button } from "@/components/ui/button" import { Progress } from "@/components/ui/progress" import { ScrollArea, type ScrollAreaProps } from "@/components/ui/scroll-area" type AttachmentState = "idle" | "uploading" | "processing" | "error" | "done" type AttachmentOrientation = "horizontal" | "vertical" const AttachmentContext = React.createContext<{ state: AttachmentState titleId: string orientation: AttachmentOrientation }>({ state: "done", titleId: "", orientation: "horizontal" }) const layoutDuration = duration.morph function subscribe() { return () => {} } function prefersReducedMotion() { return ( typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } const attachmentVariants = cva( "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-(--attachment-radius) border bg-card text-card-foreground transition-[background-color,border-color,box-shadow,scale] duration-150 ease-out-quint [--attachment-inset:1px] [--attachment-radius:var(--radius-xl)] has-[>[data-slot=attachment-trigger]]:hover:bg-muted/50 has-[>[data-slot=attachment-trigger]:focus-visible]:border-ring has-[>[data-slot=attachment-trigger]:focus-visible]:ring-3 has-[>[data-slot=attachment-trigger]:focus-visible]:ring-focus-ring data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed motion-safe:has-[>[data-slot=attachment-trigger]:active]:scale-[0.99]", { variants: { size: { default: "gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2 has-data-[slot=attachment-media]:[--attachment-inset:calc(var(--spacing)*2+1px)]", sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5 has-data-[slot=attachment-media]:[--attachment-inset:calc(var(--spacing)*1.5+1px)]", xs: "gap-1.5 text-xs [--attachment-radius:var(--radius-lg)] has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1 has-data-[slot=attachment-media]:[--attachment-inset:calc(var(--spacing)+1px)]", }, orientation: { horizontal: "min-w-40 items-center", vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30", }, }, defaultVariants: { size: "default", orientation: "horizontal", }, } ) type AttachmentProps = React.ComponentProps<"div"> & VariantProps<typeof attachmentVariants> & { state?: AttachmentState progress?: number } function clampProgress(progress: number) { return Number.isFinite(progress) ? Math.min(100, Math.max(0, progress)) : 0 } function Attachment({ className, state = "done", size = "default", orientation = "horizontal", progress, children, ref, ...props }: AttachmentProps) { const titleId = React.useId() const resolvedOrientation = orientation ?? "horizontal" const isHydrating = React.useSyncExternalStore( subscribe, () => false, () => true ) const [popIn] = React.useState(!isHydrating) const context = React.useMemo( () => ({ state, titleId, orientation: resolvedOrientation }), [state, titleId, resolvedOrientation] ) const value = state === "uploading" && progress !== undefined ? clampProgress(progress) : null return ( <AttachmentContext.Provider value={context}> <div ref={ref} data-slot="attachment" data-state={state} data-size={size} data-orientation={orientation} className={cn( attachmentVariants({ size, orientation }), popIn && "motion-safe:animate-in motion-safe:ease-out-quint motion-safe:animation-duration-200 motion-safe:fade-in-0 motion-safe:zoom-in-95", className )} {...props} > {children} {value !== null ? ( <span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"> <Progress value={value} size="xs" aria-labelledby={titleId} data-slot="attachment-progress" className="absolute inset-x-0 bottom-0 [&>[data-slot=progress-track]]:bg-transparent" /> </span> ) : null} </div> </AttachmentContext.Provider> ) } const attachmentMediaVariants = cva( "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-[max(calc(var(--radius-sm)*0.5),calc(var(--attachment-radius)-var(--attachment-inset)))] bg-muted text-foreground [--attachment-media-radius:max(calc(var(--radius-sm)*0.5),calc(var(--attachment-radius)-var(--attachment-inset)))] group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5", { variants: { variant: { icon: "", image: "opacity-60 transition-opacity duration-300 ease-out-quint group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100", }, }, defaultVariants: { variant: "icon", }, } ) function AttachmentMedia({ className, variant = "icon", children, ...props }: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) { if (variant === "image") { return ( <AspectRatio ratio={1} fallback={<IconFile />} data-slot="attachment-media" data-variant="image" className={cn(attachmentMediaVariants({ variant }), className)} {...props} > {children} </AspectRatio> ) } return ( <div data-slot="attachment-media" data-variant={variant} className={cn(attachmentMediaVariants({ variant }), className)} {...props} > {children} </div> ) } function AttachmentContent({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="attachment-content" className={cn( "max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1", className )} {...props} /> ) } function splitFileName(name: string) { const dot = name.lastIndexOf(".") if (dot <= 0 || dot === name.length - 1 || name.length - dot > 8) { return null } return { base: name.slice(0, dot), extension: name.slice(dot) } } function AttachmentTitle({ className, children, id, title, ...props }: React.ComponentProps<"span">) { const { titleId } = React.useContext(AttachmentContext) const parts = typeof children === "string" ? splitFileName(children) : null return ( <span data-slot="attachment-title" id={id ?? (titleId || undefined)} title={title ?? (typeof children === "string" ? children : undefined)} className={cn( "max-w-full min-w-0 font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer", parts ? "flex" : "block truncate", className )} {...props} > {parts ? ( <> <span className="min-w-0 truncate">{parts.base}</span> <span className="shrink-0">{parts.extension}</span> </> ) : ( children )} </span> ) } function AttachmentDescription({ className, ...props }: React.ComponentProps<"span">) { return ( <span data-slot="attachment-description" className={cn( "mt-0.5 block max-w-full min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive", className )} {...props} /> ) } function AttachmentActions({ className, ...props }: React.ComponentProps<"div">) { const { orientation } = React.useContext(AttachmentContext) return ( <div data-slot="attachment-actions" className={cn( "relative z-20 flex shrink-0 items-center", orientation === "vertical" && "absolute end-3 top-3 gap-1 transition-opacity duration-150 *:data-[slot=attachment-action]:rounded-[max(calc(var(--radius-sm)*0.5),calc(var(--attachment-radius)-var(--attachment-inset)-4px))] *:data-[slot=attachment-action]:bg-background/80 *:data-[slot=attachment-action]:shadow-xs *:data-[slot=attachment-action]:backdrop-blur-sm *:data-[slot=attachment-action]:hover:bg-background [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within/attachment:opacity-100 [@media(hover:hover)]:group-hover/attachment:opacity-100", className )} {...props} /> ) } function AttachmentAction({ variant, size = "icon-xs", ...props }: React.ComponentProps<typeof Button>) { return ( <Button data-slot="attachment-action" variant={variant ?? "ghost"} size={size} {...props} /> ) } function AttachmentTrigger({ className, render, type, ...props }: useRender.ComponentProps<"button">) { return useRender({ defaultTagName: "button", render, props: mergeProps<"button">( { type: render ? type : (type ?? "button"), className: cn( "absolute inset-0 z-10 rounded-[inherit] outline-none focus-visible:outline-hidden", className ), }, props, { "data-slot": "attachment-trigger" } as Record<string, string> ), }) } type Box = { left: number; top: number; width: number; height: number } function useGroupMotion(containerRef: React.RefObject<HTMLDivElement | null>) { const boxesRef = React.useRef(new Map<HTMLElement, Box>()) React.useLayoutEffect(() => { const container = containerRef.current if (!container) { return } const reduce = prefersReducedMotion() || typeof container.animate !== "function" const previous = boxesRef.current const next = new Map<HTMLElement, Box>() for (const child of Array.from(container.children)) { if ( !(child instanceof HTMLElement) || child.dataset.slot !== "attachment" ) { continue } const box = { left: child.offsetLeft, top: child.offsetTop, width: child.offsetWidth, height: child.offsetHeight, } next.set(child, box) const before = previous.get(child) if (!reduce && before && before.left !== box.left) { child.animate( [ { translate: `${before.left - box.left}px 0` }, { translate: "0 0" }, ], { duration: layoutDuration, easing: easeSpring } ) } } if (!reduce) { for (const [element, box] of previous) { if (next.has(element) || element.isConnected) { continue } const ghost = element.cloneNode(true) as HTMLElement ghost.removeAttribute("id") ghost .querySelectorAll("[id]") .forEach((node) => node.removeAttribute("id")) ghost.setAttribute("aria-hidden", "true") ghost.inert = true Object.assign(ghost.style, { position: "absolute", left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px`, margin: "0", pointerEvents: "none", }) container.appendChild(ghost) const exit = ghost.animate( [ { opacity: 1, scale: 1 }, { opacity: 0, scale: 0.92 }, ], { duration: 180, easing: easeOut, fill: "forwards" } ) exit.onfinish = () => ghost.remove() } } boxesRef.current = next }) } function AttachmentGroup({ className, children, ...props }: Omit<ScrollAreaProps, "scrollbars">) { const containerRef = React.useRef<HTMLDivElement>(null) useGroupMotion(containerRef) return ( <ScrollArea data-slot="attachment-group" scrollbars="horizontal" className={cn( "min-w-0 [&>[data-slot=scroll-area-viewport]]:snap-x [&>[data-slot=scroll-area-viewport]]:snap-mandatory [&>[data-slot=scroll-area-viewport]]:scroll-px-1", className )} {...props} > <div ref={containerRef} data-slot="attachment-group-list" className="relative flex w-max gap-3 p-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start" > {children} </div> </ScrollArea> ) } export { Attachment, AttachmentGroup, AttachmentMedia, AttachmentContent, AttachmentTitle, AttachmentDescription, AttachmentActions, AttachmentAction, AttachmentTrigger, attachmentVariants, } export type { AttachmentProps, AttachmentState }components/ui/aspect-ratio.tsx "use client" import * as React from "react" import { flushSync } from "react-dom" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { IconPhotoOff } from "@tabler/icons-react" import { cn } from "cn" import { Skeleton } from "@/components/ui/skeleton" type AspectRatioValue = number | `${number}/${number}` | `${number}:${number}` type MediaState = "loading" | "loaded" | "error" type AspectRatioProps = useRender.ComponentProps<"div"> & { ratio?: AspectRatioValue placeholder?: boolean fallback?: React.ReactNode } const mediaSelector = ":scope > img, :scope > picture > img, :scope > video" function parseAspectRatio(ratio: AspectRatioValue | undefined) { if (ratio === undefined) { return 1 } let value = Number.NaN if (typeof ratio === "number") { value = ratio } else { const trimmed = ratio.trim() const pair = trimmed.match(/^(\d*\.?\d+)\s*[/:]\s*(\d*\.?\d+)$/) if (pair) { value = Number(pair[1]) / Number(pair[2]) } else if (/^\d*\.?\d+$/.test(trimmed)) { value = Number(trimmed) } } if (!Number.isFinite(value) || value <= 0) { if (process.env.NODE_ENV !== "production") { console.warn( `AspectRatio: invalid ratio ${JSON.stringify(ratio)}, falling back to 1.` ) } return 1 } return value } function readMediaState(media: Element): MediaState { if (media instanceof HTMLImageElement) { if (!media.getAttribute("src") && !media.getAttribute("srcset")) { return "loading" } if (!media.complete) { return "loading" } return media.naturalWidth > 0 ? "loaded" : "error" } if (media instanceof HTMLVideoElement) { if ( media.error || media.networkState === HTMLMediaElement.NETWORK_NO_SOURCE ) { return "error" } return media.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA ? "loaded" : "loading" } return "loaded" } function useMediaState( containerRef: React.RefObject<HTMLElement | null>, enabled: boolean ) { const [state, setState] = React.useState<MediaState | null>(null) React.useLayoutEffect(() => { const container = containerRef.current if (!enabled || !container) { return } const findMedia = () => container.querySelector(mediaSelector) const sync = () => { const media = findMedia() setState(media ? readMediaState(media) : null) } const isOwn = (target: EventTarget | null) => { const media = findMedia() return ( media !== null && (target === media || (target instanceof HTMLSourceElement && target.parentElement === media)) ) } const handleLoad = (event: Event) => { if (isOwn(event.target)) { setState("loaded") } } const handleError = (event: Event) => { if (isOwn(event.target)) { setState("error") } } container.addEventListener("load", handleLoad, true) container.addEventListener("loadeddata", handleLoad, true) container.addEventListener("error", handleError, true) const observer = new MutationObserver(() => flushSync(sync)) observer.observe(container, { childList: true, subtree: true, attributes: true, attributeFilter: ["src", "srcset"], }) sync() return () => { container.removeEventListener("load", handleLoad, true) container.removeEventListener("loadeddata", handleLoad, true) container.removeEventListener("error", handleError, true) observer.disconnect() setState(null) } }, [containerRef, enabled]) return enabled ? state : null } function AspectRatio({ ratio, placeholder = true, fallback = <IconPhotoOff />, className, style, render, ref, children, ...props }: AspectRatioProps) { const containerRef = React.useRef<HTMLDivElement>(null) const value = React.useMemo(() => parseAspectRatio(ratio), [ratio]) const state = useMediaState(containerRef, placeholder) return useRender({ defaultTagName: "div", render, ref: ref ? [containerRef, ref] : containerRef, props: mergeProps<"div">( { ...({ "data-slot": "aspect-ratio" } as Record<string, string>), className: cn( "relative aspect-(--ratio) w-full [&>:is(img,video)]:object-cover [&>:is(img,video,iframe,picture)]:absolute [&>:is(img,video,iframe,picture)]:inset-0 [&>:is(img,video,iframe,picture)]:size-full [&>:is(img,video,iframe,picture)]:rounded-[inherit] [&>picture>img]:size-full [&>picture>img]:rounded-[inherit] [&>picture>img]:object-cover", "[&>:is(img,video,picture)]:transition-opacity [&>:is(img,video,picture)]:duration-300 [&>:is(img,video,picture)]:ease-out-quint data-[state=error]:[&>:is(img,video,picture)]:opacity-0 data-[state=loading]:[&>:is(img,video,picture)]:opacity-0 motion-reduce:[&>:is(img,video,picture)]:transition-none", className ), style: { ...style, "--ratio": value } as React.CSSProperties, children: ( <> {state === "loading" || state === "error" ? ( <Skeleton data-slot="aspect-ratio-placeholder" animation={state === "loading" ? "shimmer" : "none"} className="pointer-events-none absolute inset-0 rounded-[inherit]" /> ) : null} {children} {state === "error" && fallback !== null ? ( <span data-slot="aspect-ratio-fallback" aria-hidden="true" className="pointer-events-none absolute inset-0 grid animate-in place-items-center rounded-[inherit] text-muted-foreground animation-duration-200 fade-in-0 [&>svg:not([class*='size-'])]:size-6" > {fallback} </span> ) : null} </> ), "aria-busy": state === "loading" || undefined, }, props, { "data-state": state ?? undefined, } as Record<string, string | undefined> ), }) } export { AspectRatio, parseAspectRatio } export type { AspectRatioProps, AspectRatioValue }components/ui/skeleton.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" const skeletonVariants = cva( "relative overflow-hidden rounded-md bg-muted motion-safe:animate-in motion-safe:animation-duration-300 motion-safe:fade-in-0 motion-safe:fill-mode-backwards motion-safe:[animation-delay:150ms] rtl:[--skeleton-dir:-1] [&:where(span)]:inline-block [&:where(span)]:align-bottom", { variants: { animation: { shimmer: "after:pointer-events-none after:absolute after:inset-0 after:[translate:-100%_0] after:bg-linear-to-r after:from-transparent after:via-foreground/8 after:to-transparent motion-safe:after:animate-shimmer dark:after:via-foreground/10", pulse: "after:pointer-events-none after:absolute after:inset-0 after:bg-background after:opacity-0 motion-safe:after:animate-skeleton-pulse", none: "", }, }, defaultVariants: { animation: "shimmer", }, } ) type SkeletonAnimation = NonNullable< VariantProps<typeof skeletonVariants>["animation"] > type SkeletonProps = useRender.ComponentProps<"div"> & { animation?: SkeletonAnimation loading?: boolean } function useReveal(loading: boolean | undefined) { const [previous, setPrevious] = React.useState(loading) const [revealed, setRevealed] = React.useState(false) if (loading !== previous) { setPrevious(loading) setRevealed(previous === true && loading === false) } return revealed } function Skeleton({ className, animation = "shimmer", loading, render, children, ...props }: SkeletonProps) { const revealed = useReveal(loading) const wraps = loading !== undefined const showsSkeleton = !wraps || loading return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { ...({ "data-slot": "skeleton" } as Record<string, string>), className: cn( wraps && "w-fit max-w-full", showsSkeleton && skeletonVariants({ animation }), wraps && !loading && revealed && "motion-safe:animate-in motion-safe:ease-out-quint motion-safe:animation-duration-200 motion-safe:fade-in-0", className ), "aria-hidden": wraps ? undefined : true, "aria-busy": loading || undefined, children: wraps ? ( <span data-slot="skeleton-content" className={cn("contents", loading && "invisible")} inert={loading || undefined} aria-hidden={loading || undefined} > {children} </span> ) : ( children ), }, props, { "data-animation": showsSkeleton ? animation : undefined, "data-loading": wraps ? String(Boolean(loading)) : undefined, } as Record<string, string | undefined> ), }) } type SkeletonTextProps = React.ComponentProps<"div"> & { lines?: number animation?: SkeletonAnimation } function SkeletonText({ className, lines = 3, animation, ...props }: SkeletonTextProps) { const count = Math.min( 50, Math.max(1, Math.floor(Number.isFinite(lines) ? lines : 1)) ) return ( <div data-slot="skeleton-text" aria-hidden="true" className={cn("flex w-full flex-col", className)} {...props} > {Array.from({ length: count }, (_, index) => ( <div key={index} className="flex h-[1lh] items-center"> <Skeleton animation={animation} className={cn( "h-[0.8em] rounded-sm", count > 1 && index === count - 1 ? "w-3/5" : "w-full" )} /> </div> ))} </div> ) } export { Skeleton, SkeletonText, skeletonVariants } export type { SkeletonProps, SkeletonTextProps, SkeletonAnimation }components/ui/button.tsx "use client" import * as React from "react" import { Button as ButtonPrimitive } from "@base-ui/react/button" import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Spinner } from "@/components/ui/spinner" import { useButtonFeedback, type ButtonFeedbackOptions, type ButtonStatus, } from "@/hooks/use-button-feedback" const buttonVariants = cva( "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70", link: "text-primary underline-offset-4 hover:underline forced-colors:border-0", success: "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40", error: "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake", }, size: { default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5", lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", icon: "size-9 shrink-0", "icon-xs": "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden", "icon-lg": "size-10 shrink-0", }, }, defaultVariants: { variant: "default", size: "default", }, } ) type ButtonVariant = Exclude< NonNullable<VariantProps<typeof buttonVariants>["variant"]>, "success" | "error" > type ButtonClickEvent = Parameters< NonNullable<ButtonPrimitive.Props["onClick"]> >[0] type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> & Omit<VariantProps<typeof buttonVariants>, "variant"> & ButtonFeedbackOptions & { variant?: ButtonVariant | null onClick?: (event: ButtonClickEvent) => unknown feedback?: boolean loading?: boolean status?: ButtonStatus loadingLabel?: React.ReactNode successLabel?: React.ReactNode errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode) } const stateTransitionTime = 220 const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"] const quietVariants: ButtonVariant[] = ["ghost", "link"] const quietStatusClasses: Partial<Record<ButtonStatus, string>> = { success: "text-success hover:text-success", error: "text-destructive hover:text-destructive motion-safe:animate-button-shake", } function isThenable(value: unknown): value is PromiseLike<unknown> { return ( typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function" ) } function useStatusTransition(status: ButtonStatus) { const [shown, setShown] = React.useState(status) const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null) const [transitionKey, setTransitionKey] = React.useState(0) if (status !== shown) { setLeaving(shown) setShown(status) setTransitionKey((key) => key + 1) } React.useEffect(() => { if (leaving === null) { return } const timer = setTimeout(() => setLeaving(null), stateTransitionTime) return () => clearTimeout(timer) }, [leaving, transitionKey]) return { leaving, transitionKey } } function useLayerWidth(status: ButtonStatus, active: boolean) { const contentRef = React.useRef<HTMLSpanElement>(null) const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>()) const observerRef = React.useRef<ResizeObserver | null>(null) const statusRef = React.useRef(status) const applyWidth = React.useCallback(() => { const content = contentRef.current const layer = layersRef.current.get(statusRef.current) if (!content || !layer) { return } const natural = parseFloat(getComputedStyle(layer).width) if (!Number.isFinite(natural)) { return } content.style.width = `${natural}px` if (!content.hasAttribute("data-measured")) { requestAnimationFrame(() => content.setAttribute("data-measured", "")) } }, []) React.useLayoutEffect(() => { statusRef.current = status if (active) { applyWidth() } }, [active, applyWidth, status]) React.useLayoutEffect(() => { if (!active || typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(applyWidth) observerRef.current = observer layersRef.current.forEach((layer) => observer.observe(layer)) document.fonts?.addEventListener?.("loadingdone", applyWidth) return () => { observer.disconnect() observerRef.current = null document.fonts?.removeEventListener?.("loadingdone", applyWidth) } }, [active, applyWidth]) const layerRef = React.useCallback((node: HTMLElement | null) => { if (!node) { return } const layer = node.dataset.layer as ButtonStatus layersRef.current.set(layer, node) observerRef.current?.observe(node) return () => { observerRef.current?.unobserve(node) if (layersRef.current.get(layer) === node) { layersRef.current.delete(layer) } } }, []) return { contentRef, layerRef } } function composeHandlers<Event>( first: ((event: Event) => void) | undefined, second: (event: Event) => void ) { return (event: Event) => { first?.(event) second(event) } } function Button({ className, variant = "default", size = "default", children, onClick, disabled, focusableWhenDisabled, feedback, loading, status: statusProp, onStatusChange, onError, resetAfter, loadingLabel, successLabel = "Done", errorLabel = "Failed", onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: ButtonProps) { const internal = useButtonFeedback({ resetAfter, onStatusChange, onError }) const status = statusProp ?? (loading ? "loading" : internal.status) const requested = Boolean(feedback) || loading !== undefined || statusProp !== undefined const [enabled, setEnabled] = React.useState(requested) if (requested && !enabled) { setEnabled(true) } const { leaving, transitionKey } = useStatusTransition(status) const iconOnly = size?.startsWith("icon") ?? false const widthStatus = status === "loading" && loadingLabel === undefined ? "idle" : status const { contentRef, layerRef } = useLayerWidth( widthStatus, enabled && !iconOnly ) const isLoading = status === "loading" const quiet = quietVariants.includes(variant ?? "default") const resolvedVariant = !quiet && status === "success" ? "success" : !quiet && status === "error" ? "error" : variant const resolvedErrorLabel = typeof errorLabel !== "function" ? errorLabel : internal.error !== undefined ? errorLabel(internal.error) : "Failed" const handleClick = (event: ButtonClickEvent) => { if (feedback && internal.isPending()) { return } const result = onClick?.(event) if (feedback && isThenable(result)) { internal.track(result) } } const layerContent = (layer: ButtonStatus, animated: boolean) => { if (layer === "idle") { return children } if (layer === "loading") { return ( <> <Spinner aria-hidden size={null} animated={animated} /> {iconOnly ? null : loadingLabel} </> ) } if (layer === "success") { return ( <> <IconCircleCheck className={cn( animated && "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]" )} /> {iconOnly ? null : successLabel} </> ) } return ( <> <IconAlertCircle /> {iconOnly ? null : resolvedErrorLabel} </> ) } const button = ( <ButtonPrimitive data-slot="button" data-status={enabled ? status : undefined} aria-busy={isLoading || undefined} disabled={disabled || isLoading} focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)} className={cn( buttonVariants({ variant: resolvedVariant, size }), quiet && quietStatusClasses[status], className )} onClick={handleClick} onPointerEnter={composeHandlers( onPointerEnter, internal.buttonProps.onPointerEnter )} onPointerLeave={composeHandlers( onPointerLeave, internal.buttonProps.onPointerLeave )} onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)} onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)} {...props} > {enabled ? ( <span ref={contentRef} data-slot="button-content" className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]" > {statuses.map((layer) => { const active = layer === status const exiting = layer === leaving const namesButton = layer === "idle" && isLoading && loadingLabel === undefined return ( <span key={active ? `${layer}-${transitionKey}` : layer} ref={layerRef} data-slot="button-layer" data-layer={layer} aria-hidden={active || namesButton ? undefined : true} className={cn( "inline-flex items-center justify-center gap-[inherit] backface-hidden", !active && !exiting && "pointer-events-none absolute opacity-0", active && transitionKey > 0 && "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0", exiting && "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out" )} > {layerContent(layer, active || exiting)} </span> ) })} </span> ) : ( children )} </ButtonPrimitive> ) if (!enabled) { return button } const announcement = status === "loading" ? typeof loadingLabel === "string" ? loadingLabel : "Loading" : status === "success" ? typeof successLabel === "string" ? successLabel : "Done" : status === "error" ? typeof resolvedErrorLabel === "string" ? resolvedErrorLabel : "Failed" : "" return ( <> {button} <span data-slot="button-status" role="status" className="sr-only"> {announcement} </span> </> ) } export { Button, buttonVariants, useButtonFeedback } export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }components/ui/progress.tsx "use client" import * as React from "react" import { Progress as ProgressPrimitive } from "@base-ui/react/progress" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } type ProgressSize = "xs" | "sm" | "default" | "lg" type ProgressVariant = "default" | "success" | "warning" | "destructive" type ProgressCircleSize = "sm" | "default" | "lg" | "xl" const ProgressContext = React.createContext<{ kind: "bar" | "circle" size: ProgressSize | ProgressCircleSize variant: ProgressVariant } | null>(null) function useProgressContext(part: string) { const context = React.useContext(ProgressContext) if (!context) { throw new Error(`${part} must be used within <Progress>.`) } return context } function toPercent(value: number | null | undefined, min: number, max: number) { if (value == null || !Number.isFinite(value)) { return null } const percent = ((value - min) * 100) / (max - min) return Number.isNaN(percent) ? 0 : Math.min(100, Math.max(0, percent)) } const progressTrackVariants = cva( "relative flex w-full items-center overflow-hidden rounded-full bg-foreground/10 [--progress-dir:1] rtl:[--progress-dir:-1] dark:bg-foreground/15 forced-colors:border", { variants: { size: { xs: "h-0.5", sm: "h-1", default: "h-1.5", lg: "h-2.5", }, }, defaultVariants: { size: "default", }, } ) const progressIndicatorVariants = cva( "h-full w-0 rounded-full transition-[width] duration-300 ease-spring data-indeterminate:transition-none data-indeterminate:before:absolute data-indeterminate:before:inset-y-0 data-indeterminate:before:start-0 data-indeterminate:before:w-2/5 data-indeterminate:before:rounded-full data-indeterminate:before:bg-inherit motion-safe:data-indeterminate:before:animate-progress-slide motion-reduce:transition-none motion-reduce:data-indeterminate:before:w-full motion-reduce:data-indeterminate:before:animate-skeleton-pulse forced-colors:bg-highlight", { variants: { variant: { default: "bg-primary", success: "bg-success", warning: "bg-warning", destructive: "bg-destructive", }, }, defaultVariants: { variant: "default", }, } ) const progressCircleStroke = { default: "stroke-primary", success: "stroke-success", warning: "stroke-warning", destructive: "stroke-destructive", } satisfies Record<ProgressVariant, string> const progressCircleVariants = cva( "relative inline-grid size-(--progress-circle-size) shrink-0 place-items-center *:col-start-1 *:row-start-1", { variants: { size: { sm: "text-[0.5rem] [--progress-circle-size:1rem] [--progress-stroke:3px]", default: "text-[0.5rem] [--progress-circle-size:1.5rem] [--progress-stroke:2.5px]", lg: "text-[0.625rem] [--progress-circle-size:2.5rem] [--progress-stroke:2px]", xl: "text-sm [--progress-circle-size:4rem] [--progress-stroke:1.5px]", }, }, defaultVariants: { size: "default", }, } ) type ProgressProps = ProgressPrimitive.Root.Props & { size?: ProgressSize variant?: ProgressVariant } function Progress({ className, children, size = "default", variant = "default", locale = "en-US", ...props }: ProgressProps) { const context = React.useMemo( () => ({ kind: "bar" as const, size, variant }), [size, variant] ) return ( <ProgressContext.Provider value={context}> <ProgressPrimitive.Root data-slot="progress" data-size={size} data-variant={variant} locale={locale} className={mergeClassName( "flex w-full min-w-0 flex-wrap items-center gap-x-3 gap-y-2", className )} {...props} > {children} <ProgressTrack> <ProgressIndicator /> </ProgressTrack> </ProgressPrimitive.Root> </ProgressContext.Provider> ) } function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) { const { size } = useProgressContext("ProgressTrack") return ( <ProgressPrimitive.Track data-slot="progress-track" className={mergeClassName( progressTrackVariants({ size: size === "xl" ? "lg" : size }), className )} {...props} /> ) } function ProgressIndicator({ className, ...props }: ProgressPrimitive.Indicator.Props) { const { variant } = useProgressContext("ProgressIndicator") return ( <ProgressPrimitive.Indicator data-slot="progress-indicator" className={mergeClassName( progressIndicatorVariants({ variant }), className )} {...props} /> ) } function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) { return ( <ProgressPrimitive.Label data-slot="progress-label" className={mergeClassName( "min-w-0 flex-1 text-sm font-medium wrap-anywhere", className )} {...props} /> ) } function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) { const { kind } = useProgressContext("ProgressValue") return ( <ProgressPrimitive.Value data-slot="progress-value" className={mergeClassName( kind === "bar" ? "ms-auto shrink-0 text-sm text-muted-foreground tabular-nums" : "font-medium tabular-nums", className )} {...props} /> ) } type ProgressCircleProps = ProgressPrimitive.Root.Props & VariantProps<typeof progressCircleVariants> & { variant?: ProgressVariant } function ProgressCircle({ className, children, size = "default", variant = "default", value, min = 0, max = 100, locale = "en-US", ...props }: ProgressCircleProps) { const resolvedSize = size ?? "default" const context = React.useMemo( () => ({ kind: "circle" as const, size: resolvedSize, variant }), [resolvedSize, variant] ) const percent = toPercent(value, min, max) return ( <ProgressContext.Provider value={context}> <ProgressPrimitive.Root data-slot="progress-circle" data-size={resolvedSize} data-variant={variant} value={value} min={min} max={max} locale={locale} className={mergeClassName( progressCircleVariants({ size: resolvedSize }), className )} {...props} > <svg viewBox="0 0 24 24" fill="none" aria-hidden data-slot="progress-circle-svg" style={{ "--progress": percent ?? 0 } as React.CSSProperties} className="size-full -rotate-90" > <circle cx="12" cy="12" r="10" data-slot="progress-circle-track" className="stroke-foreground/10 [stroke-width:var(--progress-stroke)] dark:stroke-foreground/15" /> <circle cx="12" cy="12" r="10" pathLength={100} data-slot="progress-circle-indicator" className={cn( "origin-center [stroke-width:var(--progress-stroke)] [stroke-dasharray:100_100] [stroke-linecap:round] [transform-box:fill-box]", progressCircleStroke[variant], percent === null ? "[stroke-dasharray:25_75] motion-safe:animate-spinner-rotate motion-reduce:animate-skeleton-pulse motion-reduce:[stroke-dasharray:100_100]" : "transition-[stroke-dashoffset,opacity] duration-300 ease-spring [stroke-dashoffset:calc(100_-_var(--progress))] motion-reduce:transition-none", percent === 0 && "opacity-0" )} /> </svg> {children} </ProgressPrimitive.Root> </ProgressContext.Provider> ) } export { Progress, ProgressCircle, progressCircleVariants, ProgressIndicator, progressIndicatorVariants, ProgressLabel, ProgressTrack, progressTrackVariants, ProgressValue, } export type { ProgressCircleProps, ProgressCircleSize, ProgressProps, ProgressSize, ProgressVariant, }components/ui/scroll-area.tsx "use client" import * as React from "react" import { DirectionProvider, useDirection, } from "@base-ui/react/direction-provider" import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area" import { cva } from "class-variance-authority" import { cn } from "cn" type ClassName<State> = string | ((state: State) => string | undefined) | undefined function mergeClassName<State>(base: string, className: ClassName<State>) { return typeof className === "function" ? (state: State) => cn(base, className(state)) : cn(base, className) } function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]) { return (node: T | null) => { for (const ref of refs) { if (typeof ref === "function") { ref(node) } else if (ref) { ref.current = node } } } } type PeekItem = { top: number; height: number } function getPeekHeight(items: PeekItem[], viewportHeight: number) { if (items.length === 0 || viewportHeight <= 0) { return null } const last = items[items.length - 1] if (last.top + last.height <= viewportHeight + 1) { return null } const crossing = items.find( (item) => item.top < viewportHeight && item.top + item.height > viewportHeight ) if (crossing && crossing.height > 0) { const visible = (viewportHeight - crossing.top) / crossing.height if (visible >= 0.4 && visible <= 0.65) { return null } } let target: { height: number; visible: number } | null = null for (const item of items) { const cut = item.top + item.height * 0.6 if (cut <= viewportHeight) { target = { height: cut, visible: item.height * 0.6 } } } if (target === null || target.height < viewportHeight * 0.5) { return null } return { height: Math.round(target.height), visible: Math.round(target.visible), } } function findPeekItems(content: HTMLElement) { const marked = content.querySelectorAll<HTMLElement>( "[data-scroll-area-item]" ) if (marked.length > 0) { return Array.from(marked) } let container: Element = content while (container.children.length === 1) { container = container.children[0] } return Array.from(container.children) as HTMLElement[] } function usePeek( enabled: boolean, rootRef: React.RefObject<HTMLDivElement | null>, viewportRef: React.RefObject<HTMLDivElement | null>, contentRef: React.RefObject<HTMLDivElement | null> ) { React.useLayoutEffect(() => { const root = rootRef.current const viewport = viewportRef.current const content = contentRef.current if (!enabled || !root || !viewport || !content) { return } const apply = () => { root.style.height = "" viewport.style.removeProperty("--scroll-area-fade-end") const viewportHeight = viewport.clientHeight const origin = viewport.getBoundingClientRect().top - viewport.scrollTop const items = findPeekItems(content).map((item) => { const rect = item.getBoundingClientRect() return { top: rect.top - origin, height: rect.height } }) const target = getPeekHeight(items, viewportHeight) if (target === null) { delete root.dataset.peeking return } const chrome = root.offsetHeight - viewportHeight root.style.height = `${target.height + chrome}px` viewport.style.setProperty( "--scroll-area-fade-end", `${Math.max(8, Math.round(target.visible / 2))}px` ) root.dataset.peeking = "" } apply() const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(apply) observer?.observe(content) observer?.observe(root) return () => { observer?.disconnect() root.style.height = "" viewport.style.removeProperty("--scroll-area-fade-end") delete root.dataset.peeking } }, [enabled, rootRef, viewportRef, contentRef]) } function useDomDirection(rootRef: React.RefObject<HTMLDivElement | null>) { const contextDirection = useDirection() const [domDirection, setDomDirection] = React.useState<"ltr" | "rtl">("ltr") React.useLayoutEffect(() => { const root = rootRef.current if (root) { setDomDirection( getComputedStyle(root).direction === "rtl" ? "rtl" : "ltr" ) } }, [rootRef]) return domDirection === "rtl" ? "rtl" : contextDirection } const fadeMask = "[--scroll-area-fade:2.5rem] [mask-composite:intersect] [mask-image:linear-gradient(to_bottom,transparent,#000_min(var(--scroll-area-fade),var(--scroll-area-overflow-y-start,0px)),#000_calc(100%_-_min(var(--scroll-area-fade-end,var(--scroll-area-fade)),var(--scroll-area-overflow-y-end,0px))),transparent),linear-gradient(to_right,transparent,#000_min(var(--scroll-area-fade),var(--scroll-area-overflow-x-start,0px)),#000_calc(100%_-_min(var(--scroll-area-fade),var(--scroll-area-overflow-x-end,0px))),transparent)] rtl:[mask-image:linear-gradient(to_bottom,transparent,#000_min(var(--scroll-area-fade),var(--scroll-area-overflow-y-start,0px)),#000_calc(100%_-_min(var(--scroll-area-fade-end,var(--scroll-area-fade)),var(--scroll-area-overflow-y-end,0px))),transparent),linear-gradient(to_left,transparent,#000_min(var(--scroll-area-fade),var(--scroll-area-overflow-x-start,0px)),#000_calc(100%_-_min(var(--scroll-area-fade),var(--scroll-area-overflow-x-end,0px))),transparent)]" const scrollAreaVariants = cva( "relative has-[>[data-slot=scroll-area-viewport]:focus-visible]:ring-3 has-[>[data-slot=scroll-area-viewport]:focus-visible]:ring-focus-ring has-[>[data-slot=scroll-area-viewport]:focus-visible]:outline-1 has-[>[data-slot=scroll-area-viewport]:focus-visible]:outline-ring has-[>[data-slot=scroll-area-viewport]:focus-visible]:outline-solid" ) type ScrollAreaProps = ScrollAreaPrimitive.Root.Props & { scrollbars?: "vertical" | "horizontal" | "both" fade?: boolean peek?: boolean viewportRef?: React.Ref<HTMLDivElement> } function ScrollArea({ className, children, scrollbars = "vertical", fade = true, peek = false, viewportRef, ref, ...props }: ScrollAreaProps) { const rootRef = React.useRef<HTMLDivElement>(null) const localViewportRef = React.useRef<HTMLDivElement>(null) const contentRef = React.useRef<HTMLDivElement>(null) const setRootRef = React.useMemo(() => mergeRefs(rootRef, ref), [ref]) const setViewportRef = React.useMemo( () => mergeRefs(localViewportRef, viewportRef), [viewportRef] ) const direction = useDomDirection(rootRef) usePeek(peek, rootRef, localViewportRef, contentRef) const area = ( <ScrollAreaPrimitive.Root ref={setRootRef} data-slot="scroll-area" data-peek={peek ? "" : undefined} className={mergeClassName(scrollAreaVariants(), className)} {...props} > <ScrollAreaPrimitive.Viewport ref={setViewportRef} data-slot="scroll-area-viewport" className={cn( "size-full overscroll-none rounded-[inherit] outline-none focus-visible:outline-hidden", fade && fadeMask )} > <ScrollAreaPrimitive.Content ref={contentRef} data-slot="scroll-area-content" className={cn(scrollbars === "vertical" && "w-full min-w-0!")} > {children} </ScrollAreaPrimitive.Content> </ScrollAreaPrimitive.Viewport> {scrollbars !== "horizontal" ? ( <ScrollBar orientation="vertical" /> ) : null} {scrollbars !== "vertical" ? ( <ScrollBar orientation="horizontal" /> ) : null} {scrollbars === "both" ? ( <ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" /> ) : null} </ScrollAreaPrimitive.Root> ) return <DirectionProvider direction={direction}>{area}</DirectionProvider> } function ScrollBar({ className, orientation = "vertical", ...props }: ScrollAreaPrimitive.Scrollbar.Props) { return ( <ScrollAreaPrimitive.Scrollbar data-slot="scroll-area-scrollbar" orientation={orientation} className={mergeClassName( "pointer-events-none flex touch-none p-0.5 opacity-0 transition-opacity duration-200 select-none data-hovering:pointer-events-auto data-hovering:opacity-100 data-scrolling:pointer-events-auto data-scrolling:opacity-100 data-scrolling:duration-0 data-[orientation=horizontal]:mx-1.5 data-[orientation=horizontal]:mb-0.5 data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=vertical]:my-1.5 data-[orientation=vertical]:me-0.5 data-[orientation=vertical]:w-2.5", className )} {...props} > <ScrollAreaPrimitive.Thumb data-slot="scroll-area-thumb" className="relative flex-1 rounded-full bg-[color-mix(in_oklab,var(--foreground)_20%,var(--background))] transition-colors duration-150 hover:bg-[color-mix(in_oklab,var(--foreground)_35%,var(--background))] active:bg-[color-mix(in_oklab,var(--foreground)_45%,var(--background))] forced-colors:bg-canvas-text" /> </ScrollAreaPrimitive.Scrollbar> ) } export { ScrollArea, ScrollBar, scrollAreaVariants, getPeekHeight } export type { ScrollAreaProps, PeekItem }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"<Attachment>
<AttachmentMedia>
<IconFileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>report.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove report.pdf">
<IconX />
</AttachmentAction>
</AttachmentActions>
</Attachment>Attachment
├── AttachmentMedia
├── AttachmentContent
│ ├── AttachmentTitle
│ └── AttachmentDescription
├── AttachmentActions
│ └── AttachmentAction
└── AttachmentTrigger
AttachmentGroup
└── AttachmentImage#
variant="image" on <AttachmentMedia /> shows a square thumbnail that shimmers while it loads. Set orientation="vertical" for a tile with the preview on top.
import { IconX } from "@tabler/icons-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
export function AttachmentImage() {
return (
<div className="flex flex-wrap items-start justify-center gap-3">
<Attachment>
<AttachmentMedia variant="image">
<img src="/preview/landscape.svg" alt="" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>dolomites.png</AttachmentTitle>
<AttachmentDescription>PNG · 1.1 MB</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment orientation="vertical">
<AttachmentMedia variant="image">
<img src="/preview/landscape.svg" alt="" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>dolomites.png</AttachmentTitle>
<AttachmentDescription>PNG · 1.1 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove dolomites.png">
<IconX />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment orientation="vertical" state="uploading" progress={64}>
<AttachmentMedia variant="image">
<img src="/preview/landscape.svg" alt="" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>summit.png</AttachmentTitle>
<AttachmentDescription>Uploading… 64%</AttachmentDescription>
</AttachmentContent>
</Attachment>
</div>
)
}States#
state covers the whole upload lifecycle. Idle files get a dashed border, uploading and processing titles shimmer, and errors turn the media and description red.
import { IconFileText, IconX } from "@tabler/icons-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
type AttachmentState,
} from "@/components/ui/attachment"
const files: { state: AttachmentState; description: string }[] = [
{ state: "idle", description: "Waiting to upload" },
{ state: "uploading", description: "Uploading… 42%" },
{ state: "processing", description: "Processing" },
{ state: "error", description: "Upload failed · file too large" },
{ state: "done", description: "PDF · 2.4 MB" },
]
export function AttachmentStates() {
return (
<div className="flex flex-col gap-2">
{files.map(({ state, description }) => (
<Attachment
key={state}
state={state}
progress={state === "uploading" ? 42 : undefined}
>
<AttachmentMedia>
<IconFileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{`${state}-report.pdf`}</AttachmentTitle>
<AttachmentDescription>{description}</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label={`Remove ${state}-report.pdf`}>
<IconX />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</div>
)
}Upload flow#
Pass progress while state="uploading" to draw a thin progress bar along the bottom edge. Image previews stay dimmed until the upload is done.
"use client"
import * as React from "react"
import { IconTable } from "@tabler/icons-react"
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
type AttachmentState,
} from "@/components/ui/attachment"
import { Button } from "@/components/ui/button"
const descriptions: Record<AttachmentState, string> = {
idle: "Ready to upload",
uploading: "Uploading…",
processing: "Processing",
error: "Upload failed · connection lost",
done: "CSV · 840 KB",
}
export function AttachmentUploadFlow() {
const [state, setState] = React.useState<AttachmentState>("idle")
const [progress, setProgress] = React.useState(0)
const timer = React.useRef<ReturnType<typeof setInterval>>(undefined)
React.useEffect(() => () => clearInterval(timer.current), [])
const start = (fail: boolean) => {
clearInterval(timer.current)
setState("uploading")
setProgress(0)
let value = 0
timer.current = setInterval(() => {
value += 12
if (fail && value >= 60) {
clearInterval(timer.current)
setState("error")
return
}
if (value >= 100) {
clearInterval(timer.current)
setProgress(100)
setState("processing")
setTimeout(() => setState("done"), 900)
return
}
setProgress(value)
}, 250)
}
return (
<div className="flex flex-col items-start gap-3">
<Attachment state={state} progress={progress}>
<AttachmentMedia>
<IconTable />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>customers-2026.csv</AttachmentTitle>
<AttachmentDescription>
{state === "uploading"
? `Uploading… ${progress}%`
: descriptions[state]}
</AttachmentDescription>
</AttachmentContent>
</Attachment>
<div className="flex gap-2">
<Button size="sm" onClick={() => start(false)}>
Start upload
</Button>
<Button size="sm" variant="outline" onClick={() => start(true)}>
Upload that fails
</Button>
</div>
</div>
)
}Sizes#
size scales the padding, media and text together, and keeps the inner corners concentric with the card.
import { IconFileText, IconX } from "@tabler/icons-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
const sizes = ["default", "sm", "xs"] as const
export function AttachmentSizes() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
{sizes.map((size) => (
<Attachment key={size} size={size}>
<AttachmentMedia>
<IconFileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>invoice.pdf</AttachmentTitle>
<AttachmentDescription>{size}</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove invoice.pdf">
<IconX />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</div>
)
}Long names#
Long file names truncate before the extension, so people can still tell a .pdf from a .zip. Hover the name to read it in full.
import { IconFileText } from "@tabler/icons-react"
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
export function AttachmentLongNames() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Attachment>
<AttachmentMedia>
<IconFileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>
quarterly-sales-report-final-v3-approved-by-finance.pdf
</AttachmentTitle>
<AttachmentDescription>PDF · 5.2 MB</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment>
<AttachmentMedia>
<IconFileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>
a-file-without-any-extension-at-all-just-a-long-name
</AttachmentTitle>
<AttachmentDescription>File · 12 KB</AttachmentDescription>
</AttachmentContent>
</Attachment>
</div>
)
}Group#
<AttachmentGroup /> lays attachments out in a row that scrolls sideways, with edges that fade and items that snap into place.
import { IconFileZip } from "@tabler/icons-react"
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
const files = Array.from(
{ length: 10 },
(_, index) => `export-${index + 1}.zip`
)
export function AttachmentGroupDemo() {
return (
<AttachmentGroup className="w-full rounded-lg border">
{files.map((name) => (
<Attachment key={name}>
<AttachmentMedia>
<IconFileZip />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{name}</AttachmentTitle>
<AttachmentDescription>ZIP · 18 MB</AttachmentDescription>
</AttachmentContent>
</Attachment>
))}
</AttachmentGroup>
)
}Trigger#
<AttachmentTrigger /> makes the whole card clickable, for example to open a preview or a link, while the actions on it stay independent. Give it an aria-label.
import { IconDownload, IconFileZip, IconX } from "@tabler/icons-react"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/components/ui/attachment"
export function AttachmentTriggerDemo() {
return (
<div className="flex flex-wrap justify-center gap-3">
<AlertDialog>
<Attachment>
<AttachmentMedia>
<IconFileZip />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>research-summary.zip</AttachmentTitle>
<AttachmentDescription>ZIP · 4.8 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Remove research-summary.zip">
<IconX />
</AttachmentAction>
</AttachmentActions>
<AlertDialogTrigger
render={
<AttachmentTrigger aria-label="Preview research-summary.zip" />
}
/>
</Attachment>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>research-summary.zip</AlertDialogTitle>
<AlertDialogDescription>
12 files · 4.8 MB · Uploaded today
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Close</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Attachment>
<AttachmentMedia variant="image">
<img src="/preview/landscape.svg" alt="" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>workspace.png</AttachmentTitle>
<AttachmentDescription>Opens in a new tab</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="Download workspace.png">
<IconDownload />
</AttachmentAction>
</AttachmentActions>
<AttachmentTrigger
render={
<a
href="/preview/landscape.svg"
target="_blank"
rel="noreferrer"
aria-label="Open workspace.png"
/>
}
/>
</Attachment>
</div>
)
}Composer#
Files added after the page loads pop in, and the rest of the row slides over smoothly when one is removed.
"use client"
import * as React from "react"
import { IconFileText, IconPlus, IconX } from "@tabler/icons-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
import { Button } from "@/components/ui/button"
export function AttachmentComposer() {
const [files, setFiles] = React.useState([
{ id: 1, name: "brief.pdf" },
{ id: 2, name: "moodboard.fig" },
{ id: 3, name: "timeline.xlsx" },
])
return (
<div className="flex w-full flex-col gap-3">
<AttachmentGroup className="rounded-lg border">
{files.map((file) => (
<Attachment key={file.id} size="sm">
<AttachmentMedia>
<IconFileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{file.name}</AttachmentTitle>
<AttachmentDescription>Attached</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${file.name}`}
onClick={() =>
setFiles((current) =>
current.filter((item) => item.id !== file.id)
)
}
>
<IconX />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</AttachmentGroup>
<Button
size="sm"
variant="outline"
className="self-start"
onClick={() =>
setFiles((current) => [
...current,
{ id: Date.now(), name: `notes-${current.length + 1}.txt` },
])
}
>
<IconPlus data-icon="inline-start" />
Add file
</Button>
</div>
)
}Broken image#
When an image preview fails to load, it falls back to a file icon.

import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
export function AttachmentBrokenImage() {
return (
<Attachment>
<AttachmentMedia variant="image">
<img src="/preview/missing.png" alt="" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>missing.png</AttachmentTitle>
<AttachmentDescription>Preview unavailable</AttachmentDescription>
</AttachmentContent>
</Attachment>
)
}Stress#
Hostile names in a narrow column, a random state every 60 ms, and 200 items in one group. Nothing overflows and the layout holds.
"use client"
import * as React from "react"
import { IconFileText, IconX } from "@tabler/icons-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
type AttachmentState,
} from "@/components/ui/attachment"
import { Button } from "@/components/ui/button"
const names = [
"x".repeat(300) + ".pdf",
"archive.tar.gz",
"🎉🎉🎉 party photo final FINAL (2).png",
"<script>alert(1)</script>.html",
"a".repeat(400),
".gitignore",
"تقرير-المبيعات-الربعي-النهائي-للإدارة.pdf",
"名前がとても長いファイルの例です.docx",
"",
"file.with.many.dots.in.its.name.json",
]
const states: AttachmentState[] = [
"idle",
"uploading",
"processing",
"error",
"done",
]
const bulk = Array.from(
{ length: 200 },
(_, index) => `bulk-export-${index + 1}.csv`
)
export function AttachmentStress() {
const [tick, setTick] = React.useState(0)
const [running, setRunning] = React.useState(false)
React.useEffect(() => {
if (!running) {
return
}
const timer = setInterval(() => setTick((value) => value + 1), 60)
return () => clearInterval(timer)
}, [running])
return (
<div className="flex w-full flex-col items-start gap-3">
<div className="flex w-56 flex-col gap-2 rounded-lg border border-dashed p-2">
{names.map((name, index) => {
const state = running
? states[(tick + index) % states.length]
: "done"
return (
<Attachment
key={index}
state={state}
progress={((tick * 37 + index * 13) % 160) - 30}
>
<AttachmentMedia>
<IconFileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{name}</AttachmentTitle>
<AttachmentDescription>
{state === "error"
? "Upload failed because the server rejected this extremely long file name"
: state}
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label={`Remove ${name || "file"}`}>
<IconX />
</AttachmentAction>
</AttachmentActions>
</Attachment>
)
})}
</div>
<Button size="sm" variant="outline" onClick={() => setRunning(!running)}>
{running ? "Stop chaos" : "Start chaos"}
</Button>
<AttachmentGroup className="w-full rounded-lg border">
{bulk.map((name) => (
<Attachment key={name} size="xs">
<AttachmentMedia>
<IconFileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{name}</AttachmentTitle>
</AttachmentContent>
</Attachment>
))}
</AttachmentGroup>
</div>
)
}Right to left#
Media, content and actions mirror, and the progress bar fills from the start edge.
import { IconFileText, IconX } from "@tabler/icons-react"
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from "@/components/ui/attachment"
export function AttachmentRtl() {
return (
<div dir="rtl">
<Attachment>
<AttachmentMedia>
<IconFileText />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>تقرير-المبيعات-الربعي-النهائي.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 ميغابايت</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="إزالة الملف">
<IconX />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</div>
)
}| Key | Action |
|---|---|
| Tab | Moves focus to each action, then to the trigger. Hidden actions on vertical tiles appear when focus enters the card. |
| EnterSpace | Activates the focused action or trigger. |
- While uploading, the progress bar has
role="progressbar"and is labelled by the title. - String titles get a
titleattribute with the full name, so truncated names can still be read. - Icon-only actions and the trigger have no text, so always give them an
aria-labelthat names the file. - With reduced motion on, attachments appear without popping in, the progress bar jumps instead of easing, and groups reflow instantly.
Every part except the group, the action and the trigger renders a plain element and accepts its attributes. The card styles are exported as attachmentVariants.
| Prop | Type | Default |
|---|---|---|
state | "idle" | "uploading" | "processing" | "error" | "done" | "done" |
progress0 to 100. Shown only while uploading, and clamped to that range. | number | – |
size | "default" | "sm" | "xs" | "default" |
orientation | "horizontal" | "vertical" | "horizontal" |
| Attribute | Description |
|---|---|
data-slot="attachment" | Target the card in CSS. |
data-state | The current state. |
data-size | The current size. |
data-orientation | The current orientation. |
data-slot="attachment-progress" | The progress bar, present while uploading. |
--attachment-radius | The card’s corner radius. Inner corners are derived from it. |
| Prop | Type | Default |
|---|---|---|
variantimage wraps its child in a square AspectRatio with a file icon fallback. | "icon" | "image" | "icon" |
| Attribute | Description |
|---|---|
data-slot="attachment-media" | Target the media in CSS. |
data-variant | The current variant. |
A <div> that holds the title and description and takes the remaining width.
| Attribute | Description |
|---|---|
data-slot="attachment-content" | Target the content in CSS. |
| Prop | Type | Default |
|---|---|---|
childrenA string is split so its extension never truncates. | ReactNode | – |
titleShown on hover. Defaults to the full name. | string | children |
idGenerated when omitted. The progress bar is labelled by it. | string | – |
| Attribute | Description |
|---|---|
data-slot="attachment-title" | Target the title in CSS. |
A single-line <span> for the size, type or status. It truncates when space runs out.
| Attribute | Description |
|---|---|
data-slot="attachment-description" | Target the description in CSS. |
A <div> for the action buttons. On vertical tiles it floats over the preview and, on devices with a mouse, appears on hover or focus.
| Attribute | Description |
|---|---|
data-slot="attachment-actions" | Target the actions in CSS. |
A <Button /> that sits above the trigger. It accepts every Button prop.
| Prop | Type | Default |
|---|---|---|
variant | ButtonVariant | "ghost" |
size | ButtonSize | "icon-xs" |
| Attribute | Description |
|---|---|
data-slot="attachment-action" | Target actions in CSS. |
An invisible layer that covers the whole card. Compose it with a dialog trigger, a link or any button.
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <button type="button"> |
| Attribute | Description |
|---|---|
data-slot="attachment-trigger" | Target the trigger in CSS. |
A horizontal <ScrollArea /> that snaps to each attachment and animates the row when items are added or removed. It accepts every ScrollArea prop except scrollbars.
| Attribute | Description |
|---|---|
data-slot="attachment-group" | Target the group in CSS. |
data-slot="attachment-group-list" | The row that holds the attachments. |