# Attachment

> File and image cards for uploads, with progress, states, actions, a full-card trigger and names that keep their extension.

Docs: https://hextaui.com/docs/attachment
Markdown: https://hextaui.com/docs/attachment.md

```tsx title="components/examples/attachment/demo.tsx"
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>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/attachment.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="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 }
```

```tsx title="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 }
```

```tsx title="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 }
```

```tsx title="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 }
```

```tsx title="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,
}
```

```tsx title="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 }
```

```ts title="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 }
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
} from "@/components/ui/attachment"
```

```tsx
<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>
```

## Composition

```text
Attachment
├── AttachmentMedia
├── AttachmentContent
│   ├── AttachmentTitle
│   └── AttachmentDescription
├── AttachmentActions
│   └── AttachmentAction
└── AttachmentTrigger

AttachmentGroup
└── Attachment
```

## Examples

### Image

`variant="image"` on `<AttachmentMedia />` shows a square thumbnail that shimmers while it loads. Set `orientation="vertical"` for a tile with the preview on top.

```tsx title="components/examples/attachment/image.tsx"
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.

```tsx title="components/examples/attachment/states.tsx"
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.

```tsx title="components/examples/attachment/upload-flow.tsx"
"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.

```tsx title="components/examples/attachment/sizes.tsx"
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.

```tsx title="components/examples/attachment/long-names.tsx"
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.

```tsx title="components/examples/attachment/group.tsx"
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`.

```tsx title="components/examples/attachment/trigger.tsx"
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.

```tsx title="components/examples/attachment/composer.tsx"
"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.

```tsx title="components/examples/attachment/broken-image.tsx"
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.

```tsx title="components/examples/attachment/stress.tsx"
"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.

```tsx title="components/examples/attachment/rtl.tsx"
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>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves focus to each action, then to the trigger. Hidden actions on vertical tiles appear when focus enters the card. |
| `Enter` `Space` | Activates the focused action or trigger. |

## Accessibility

- While uploading, the progress bar has `role="progressbar"` and is labelled by the title.
- String titles get a `title` attribute 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-label` that names the file.
- With reduced motion on, attachments appear without popping in, the progress bar jumps instead of easing, and groups reflow instantly.

## API reference

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`.

### Attachment

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `state` | `"idle" \| "uploading" \| "processing" \| "error" \| "done"` | `"done"` |  |
| `progress` | `number` | – | 0 to 100. Shown only while uploading, and clamped to that range. |
| `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. |

### AttachmentMedia

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"icon" \| "image"` | `"icon"` | image wraps its child in a square AspectRatio with a file icon fallback. |

| Attribute | Description |
| --- | --- |
| `data-slot="attachment-media"` | Target the media in CSS. |
| `data-variant` | The current variant. |

### AttachmentContent

A `<div>` that holds the title and description and takes the remaining width.

| Attribute | Description |
| --- | --- |
| `data-slot="attachment-content"` | Target the content in CSS. |

### AttachmentTitle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | – | A string is split so its extension never truncates. |
| `title` | `string` | `children` | Shown on hover. Defaults to the full name. |
| `id` | `string` | – | Generated when omitted. The progress bar is labelled by it. |

| Attribute | Description |
| --- | --- |
| `data-slot="attachment-title"` | Target the title in CSS. |

### AttachmentDescription

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. |

### AttachmentActions

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. |

### AttachmentAction

A `<Button />` that sits above the trigger. It accepts every Button prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `ButtonVariant` | `"ghost"` |  |
| `size` | `ButtonSize` | `"icon-xs"` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="attachment-action"` | Target actions in CSS. |

### AttachmentTrigger

An invisible layer that covers the whole card. Compose it with a dialog trigger, a link or any button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<button type="button">` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="attachment-trigger"` | Target the trigger in CSS. |

### AttachmentGroup

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. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
