Responsive Sheet
Organism
Mesma intenção, superfície diferente: side sheet no desktop, bottom sheet no mobile.
Use quando o conteúdo precisa continuar conectado ao item de origem: detalhe de menção, filtros avançados, edição contextual, Cris embedding, handover ou ação assistida.
Exemplos copiáveis
React
React
import * as React from "react"
type PulsoResponsiveSheetProps = {
open: boolean
title: string
description?: string
children: React.ReactNode
controls?: React.ReactNode
onOpenChange: (open: boolean) => void
}
export function PulsoResponsiveSheet({ open, title, description, children, controls, onOpenChange }: PulsoResponsiveSheetProps) {
const titleId = React.useId()
const descriptionId = description ? `${titleId}-description` : undefined
React.useEffect(() => {
if (!open) return
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onOpenChange(false)
}
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
}, [open, onOpenChange])
if (!open) return null
return (
<div className="fixed inset-0 z-50 bg-black/35" role="presentation">
<aside role="dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descriptionId} className="fixed inset-x-0 bottom-0 max-h-[88dvh] overflow-hidden rounded-t-[var(--radius-xl)] border border-[var(--border)] bg-[var(--popover)] shadow-[var(--shadow-lg)] motion-safe:duration-[var(--motion-duration-base)] md:inset-y-0 md:left-auto md:w-[min(480px,calc(100vw-3rem))] md:max-h-none md:rounded-none md:rounded-l-[var(--radius-xl)]">
<div className="mx-auto mt-3 h-1.5 w-10 rounded-full bg-[var(--border-strong)] md:hidden" />
<header className="flex items-start justify-between gap-4 border-b border-[var(--border)] p-5">
<div>
<h2 id={titleId} className="text-lg font-semibold text-[var(--fg)]">{title}</h2>
{description ? <p id={descriptionId} className="mt-1 text-sm leading-6 text-[var(--fg-muted)]">{description}</p> : null}
</div>
<button type="button" aria-label="Fechar" onClick={() => onOpenChange(false)} className="rounded-[var(--radius-sm)] px-2 py-1 text-[var(--fg-muted)] hover:bg-[var(--muted)]">×</button>
</header>
<div className="max-h-[calc(88dvh-9rem)] overflow-y-auto p-5 md:max-h-[calc(100dvh-9rem)]">{children}</div>
{controls ? <footer className="border-t border-[var(--border)] p-4"><div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">{controls}</div></footer> : null}
</aside>
</div>
)
}Composição interna (Padrão 8)
O Sheet tem três regiões com papéis fixos:
| Região | Regra |
|---|---|
| Header | fixo; kicker + título + descrição — o conteúdo não repete esse título |
| Conteúdo | scrollável (min-h-0 flex-1); seções separadas por divisor + espaço em branco, nunca por caixas com borda |
| Footer | fixo; ação principal à esquerda |
Regra de contenção única: dentro de um Sheet, no máximo um nível de contenção visual (tint suave sem borda, ex. bg-muted/10) — nunca bloco com borda dentro de bloco com borda. Hierarquia interna se faz com kicker/título/divisor, não com molduras. Métricas resumidas viram linhas de definição (kicker em cima, valor embaixo), não grade de caixinhas.
Anti-padrão: repetir o título do Sheet numa “caixa-mãe” interna que envolve todo o conteúdo — o header já cumpre esse papel.
Regras
- Não use Responsive Sheet para alerta bloqueante; use Dialog.
- Adapte superfície, não duplique conteúdo entre desktop e mobile.
- Side sheet deve preservar contexto da lista por trás.
- Bottom sheet deve respeitar polegar, gesto de fechamento e scroll interno.
- Conteúdo assistido por IA precisa manter rastreabilidade visível.
- Sheet nunca abre outra Sheet (sem empilhamento); ações destrutivas em DropdownMenu, não expostas.