HextaUI

Attachment

Tarjetas de archivos e imágenes para subidas, con progreso, estados, acciones, un trigger de tarjeta completa y nombres que conservan su extensión.

sales-dashboard.pdfPDF · 2.4 MB
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.json

Añade el componente, los tokens del tema de HextaUI y los componentes de HextaUI de los que depende.

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
└── Attachment

Imagen

variant="image" en <AttachmentMedia /> muestra una miniatura cuadrada con shimmer mientras carga. Define orientation="vertical" para un mosaico con la vista previa arriba.

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>
  )
}

Estados

state cubre todo el ciclo de vida de la subida. Los archivos en reposo (idle) tienen un borde discontinuo, los títulos en subida (uploading) y procesamiento (processing) tienen shimmer, y los errores tiñen de rojo el elemento multimedia y la descripción.

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>
  )
}

Flujo de subida

Pasa progress mientras state="uploading" para dibujar una barra de progreso fina a lo largo del borde inferior. Las vistas previas de imagen permanecen atenuadas hasta que termina la subida.

"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>
  )
}

Tamaños

size escala el relleno, el elemento multimedia y el texto a la vez, y mantiene las esquinas interiores concéntricas con la tarjeta.

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>
  )
}

Nombres largos

Los nombres de archivo largos se truncan antes de la extensión, para que se siga distinguiendo un .pdf de un .zip. Pasa el cursor sobre el nombre para leerlo completo.

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 /> coloca los archivos adjuntos en una fila con desplazamiento lateral, con bordes que se desvanecen y elementos que se ajustan a su posición.

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 /> hace clicable toda la tarjeta, por ejemplo para abrir una vista previa o un enlace, mientras las acciones que contiene siguen siendo independientes. Dale un 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>
  )
}

Compositor

Los archivos añadidos después de cargar la página aparecen con un efecto pop, y el resto de la fila se desliza suavemente cuando se quita uno.

"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>
  )
}

Imagen rota

Cuando falla la carga de la vista previa de una imagen, se recurre a un icono de archivo.

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>
  )
}

Estrés

Nombres hostiles en una columna estrecha, un estado aleatorio cada 60 ms y 200 elementos en un grupo. Nada se desborda y el diseño aguanta.

"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>
  )
}

De derecha a izquierda

El elemento multimedia, el contenido y las acciones se reflejan, y la barra de progreso se llena desde el borde inicial.

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>
  )
}
KeyAcción
TabMueve el foco a cada acción y luego al trigger. Las acciones ocultas en mosaicos verticales aparecen cuando el foco entra en la tarjeta.
EnterSpaceActiva la acción o el trigger con foco.
  • Durante la subida, la barra de progreso tiene role="progressbar" y se etiqueta con el título.
  • Los títulos de tipo cadena reciben un atributo title con el nombre completo, de modo que los nombres truncados se puedan leer igualmente.
  • Las acciones solo con icono y el trigger no tienen texto, así que dales siempre un aria-label que nombre el archivo.
  • Con movimiento reducido, los adjuntos aparecen sin efecto pop, la barra de progreso salta en lugar de suavizarse y los grupos se reacomodan al instante.

Todas las partes excepto el grupo, la acción y el trigger renderizan un elemento simple y aceptan sus atributos. Los estilos de la tarjeta se exportan como attachmentVariants.

PropTipoPredeterminado
state
"idle" | "uploading" | "processing" | "error" | "done""done"
progressDe 0 a 100. Se muestra solo durante la subida, y se limita a ese rango.
number–
size
"default" | "sm" | "xs""default"
orientation
"horizontal" | "vertical""horizontal"
AtributoDescripción
data-slot="attachment"Apunta a la tarjeta en CSS.
data-stateEl estado actual.
data-sizeEl tamaño actual.
data-orientationLa orientación actual.
data-slot="attachment-progress"La barra de progreso, presente durante la subida.
--attachment-radiusEl radio de las esquinas de la tarjeta. Las esquinas interiores se derivan de él.
PropTipoPredeterminado
variantimage envuelve a su hijo en un AspectRatio cuadrado con un icono de archivo como fallback.
"icon" | "image""icon"
AtributoDescripción
data-slot="attachment-media"Selecciona el elemento multimedia en CSS.
data-variantLa variante actual.

Un <div> que contiene el título y la descripción y ocupa el ancho restante.

AtributoDescripción
data-slot="attachment-content"Selecciona el contenido en CSS.
PropTipoPredeterminado
childrenUna cadena se divide para que su extensión nunca se trunque.
ReactNode–
titleSe muestra al pasar el cursor. Por defecto es el nombre completo.
stringchildren
idSe genera si se omite. La barra de progreso se etiqueta con él.
string–
AtributoDescripción
data-slot="attachment-title"Apunta al título en CSS.

Un <span> de una sola línea para el tamaño, el tipo o el estado. Se trunca cuando se acaba el espacio.

AtributoDescripción
data-slot="attachment-description"Apunta a la descripción en CSS.

Un <div> para los botones de acción. En mosaicos verticales flota sobre la vista previa y, en dispositivos con ratón, aparece al pasar el cursor o al recibir foco.

AtributoDescripción
data-slot="attachment-actions"Selecciona las acciones en CSS.

Un <Button /> que se sitúa sobre el trigger. Acepta todas las props de Button.

PropTipoPredeterminado
variant
ButtonVariant"ghost"
size
ButtonSize"icon-xs"
AtributoDescripción
data-slot="attachment-action"Selecciona las acciones en CSS.

Una capa invisible que cubre toda la tarjeta. Compónla con un trigger de diálogo, un enlace o cualquier botón.

PropTipoPredeterminado
render
ReactElement | (props, state) => ReactElement<button type="button">
AtributoDescripción
data-slot="attachment-trigger"Apunta al trigger en CSS.

Un <ScrollArea /> horizontal que se ajusta a cada adjunto y anima la fila cuando se añaden o quitan elementos. Acepta todas las props de ScrollArea excepto scrollbars.

AtributoDescripción
data-slot="attachment-group"Apunta al grupo en CSS.
data-slot="attachment-group-list"La fila que contiene los adjuntos.

Usado en bloques

Bloques que se construyen sobre Attachment.