HextaUI

Attachment

Cards de arquivo e imagem para uploads, com progresso, estados, ações, um gatilho no card inteiro e nomes que mantêm a extensão.

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

Adiciona o componente, os tokens de tema do HextaUI e quaisquer componentes do HextaUI dos quais ele 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

Imagem

variant="image" em <AttachmentMedia /> exibe uma miniatura quadrada com shimmer enquanto carrega. Defina orientation="vertical" para um tile com a prévia no topo.

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 cobre todo o ciclo de vida do envio. Arquivos idle ganham borda tracejada, os títulos de uploading e processing têm shimmer, e os erros deixam a mídia e a descrição em vermelho.

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

Fluxo de envio

Passe progress enquanto state="uploading" para desenhar uma barra de progresso fina na borda inferior. As prévias de imagem ficam esmaecidas até o envio terminar.

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

Tamanhos

size escala o padding, a mídia e o texto em conjunto e mantém os cantos internos concêntricos com o card.

import { IconFileText, IconX } from "@tabler/icons-react"

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

const sizes = ["default", "sm", "xs"] as const

export function AttachmentSizes() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      {sizes.map((size) => (
        <Attachment key={size} size={size}>
          <AttachmentMedia>
            <IconFileText />
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>invoice.pdf</AttachmentTitle>
            <AttachmentDescription>{size}</AttachmentDescription>
          </AttachmentContent>
          <AttachmentActions>
            <AttachmentAction aria-label="Remove invoice.pdf">
              <IconX />
            </AttachmentAction>
          </AttachmentActions>
        </Attachment>
      ))}
    </div>
  )
}

Nomes longos

Nomes de arquivo longos são truncados antes da extensão, para que ainda dê para distinguir um .pdf de um .zip. Passe o mouse sobre o nome para lê-lo por inteiro.

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

Grupo

<AttachmentGroup /> dispõe os anexos em uma linha que rola na horizontal, com bordas que esmaecem e itens que se encaixam no lugar.

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

Gatilho

<AttachmentTrigger /> torna o card inteiro clicável, por exemplo para abrir uma prévia ou um link, enquanto as ações nele continuam independentes. Dê a ele um aria-label.

import { IconDownload, IconFileZip, IconX } from "@tabler/icons-react"

import {
  AlertDialog,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentMedia,
  AttachmentTitle,
  AttachmentTrigger,
} from "@/components/ui/attachment"

export function AttachmentTriggerDemo() {
  return (
    <div className="flex flex-wrap justify-center gap-3">
      <AlertDialog>
        <Attachment>
          <AttachmentMedia>
            <IconFileZip />
          </AttachmentMedia>
          <AttachmentContent>
            <AttachmentTitle>research-summary.zip</AttachmentTitle>
            <AttachmentDescription>ZIP · 4.8 MB</AttachmentDescription>
          </AttachmentContent>
          <AttachmentActions>
            <AttachmentAction aria-label="Remove research-summary.zip">
              <IconX />
            </AttachmentAction>
          </AttachmentActions>
          <AlertDialogTrigger
            render={
              <AttachmentTrigger aria-label="Preview research-summary.zip" />
            }
          />
        </Attachment>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>research-summary.zip</AlertDialogTitle>
            <AlertDialogDescription>
              12 files · 4.8 MB · Uploaded today
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>Close</AlertDialogCancel>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
      <Attachment>
        <AttachmentMedia variant="image">
          <img src="/preview/landscape.svg" alt="" />
        </AttachmentMedia>
        <AttachmentContent>
          <AttachmentTitle>workspace.png</AttachmentTitle>
          <AttachmentDescription>Opens in a new tab</AttachmentDescription>
        </AttachmentContent>
        <AttachmentActions>
          <AttachmentAction aria-label="Download workspace.png">
            <IconDownload />
          </AttachmentAction>
        </AttachmentActions>
        <AttachmentTrigger
          render={
            <a
              href="/preview/landscape.svg"
              target="_blank"
              rel="noreferrer"
              aria-label="Open workspace.png"
            />
          }
        />
      </Attachment>
    </div>
  )
}

Composer

Arquivos adicionados depois do carregamento da página surgem com um pop, e o resto da linha desliza suavemente quando um é removido.

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

Imagem quebrada

Quando a prévia de uma imagem não carrega, ela recorre a um ícone de arquivo.

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

Estresse

Nomes hostis em uma coluna estreita, um estado aleatório a cada 60 ms e 200 itens em um grupo. Nada transborda e o layout se mantém.

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

Da direita para a esquerda

A mídia, o conteúdo e as ações são espelhados, e a barra de progresso preenche a partir da borda 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>
  )
}
TeclaAção
TabMove o foco para cada ação e depois para o gatilho. Ações ocultas em tiles verticais aparecem quando o foco entra no card.
EnterSpaceAtiva a ação ou o gatilho em foco.
  • Durante o envio, a barra de progresso tem role="progressbar" e é rotulada pelo título.
  • Títulos em string recebem um atributo title com o nome completo, para que nomes truncados ainda possam ser lidos.
  • As ações só com ícone e o gatilho não têm texto, então sempre dê a eles um aria-label com o nome do arquivo.
  • Com movimento reduzido ativado, os anexos aparecem sem o efeito pop, a barra de progresso salta em vez de suavizar, e os grupos se reorganizam instantaneamente.

Todas as partes, exceto o grupo, a ação e o gatilho, renderizam um elemento simples e aceitam seus atributos. Os estilos do card são exportados como attachmentVariants.

PropTipoPadrão
state
"idle" | "uploading" | "processing" | "error" | "done""done"
progressDe 0 a 100. Exibido apenas durante o envio e limitado a esse intervalo.
number–
size
"default" | "sm" | "xs""default"
orientation
"horizontal" | "vertical""horizontal"
AtributoDescrição
data-slot="attachment"Selecione o card no CSS.
data-stateO estado atual.
data-sizeO tamanho atual.
data-orientationA orientação atual.
data-slot="attachment-progress"A barra de progresso, presente durante o envio.
--attachment-radiusO raio dos cantos do card. Os cantos internos são derivados dele.
PropTipoPadrão
variantimage envolve seu filho em um AspectRatio quadrado com um ícone de arquivo como fallback.
"icon" | "image""icon"
AtributoDescrição
data-slot="attachment-media"Seleciona a mídia no CSS.
data-variantA variante atual.

Um <div> que contém o título e a descrição e ocupa a largura restante.

AtributoDescrição
data-slot="attachment-content"Seleciona o conteúdo no CSS.
PropTipoPadrão
childrenUma string é dividida para que sua extensão nunca seja truncada.
ReactNode–
titleExibido ao passar o mouse. O padrão é o nome completo.
stringchildren
idGerado quando omitido. A barra de progresso é rotulada por ele.
string–
AtributoDescrição
data-slot="attachment-title"Selecione o título no CSS.

Um <span> de uma linha para o tamanho, o tipo ou o status. Ele é truncado quando falta espaço.

AtributoDescrição
data-slot="attachment-description"Selecione a descrição no CSS.

Um <div> para os botões de ação. Em tiles verticais ele flutua sobre a prévia e, em dispositivos com mouse, aparece ao passar o mouse ou ao receber foco.

AtributoDescrição
data-slot="attachment-actions"Seleciona as ações no CSS.

Um <Button /> que fica acima do gatilho. Aceita todas as props de Button.

PropTipoPadrão
variant
ButtonVariant"ghost"
size
ButtonSize"icon-xs"
AtributoDescrição
data-slot="attachment-action"Seleciona as ações no CSS.

Uma camada invisível que cobre o card inteiro. Combine-a com um gatilho de diálogo, um link ou qualquer botão.

PropTipoPadrão
render
ReactElement | (props, state) => ReactElement<button type="button">
AtributoDescrição
data-slot="attachment-trigger"Selecione o gatilho no CSS.

Um <ScrollArea /> horizontal que se encaixa em cada anexo e anima a linha quando itens são adicionados ou removidos. Aceita todas as props de ScrollArea, exceto scrollbars.

AtributoDescrição
data-slot="attachment-group"Selecione o grupo no CSS.
data-slot="attachment-group-list"A linha que contém os anexos.

Usado em blocos

Blocos que se baseiam em Attachment.