Saltar al contenido
ssntl/ui

Sheet

Un panel inferior que se arrastra para cerrar: te sigue, avisa cuándo soltar lo cerrará y regresa si no.

Instalación

$ pnpm dlx shadcn@latest add @ssntl/sheet

Uso

tsx
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from "@/components/ui/sheet"

<Sheet>
  <SheetTrigger asChild><Button>Detalle del pedido</Button></SheetTrigger>
  <SheetContent closeLabel="Cerrar" releaseLabel="Suelta para cerrar">
    <SheetTitle>Pedido GT-48210</SheetTitle>
    <SheetDescription>Listo para recoger hoy.</SheetDescription>
  </SheetContent>
</Sheet>

Reglas

Lo que este componente espera de ti, tal como se instala en components/ui/sheet.md junto al código. El archivo completo agrega cuándo usarlo, teclado, los fundamentos de UI que aplica y cada regla compartida (en inglés).

components/ui/sheet.md
## Reglas de Sheet

- **Gesto inmediato.** El panel sigue al puntero 1:1 desde el primer píxel; arrastrar hacia arriba encuentra resistencia.
- **Gesto continuo.** El puntero queda capturado, así el arrastre sobrevive aunque salga de la manija.
- **Anticipación.** Pasado el umbral, la manija se estira y dice “Suelta para cerrar”.
- **Gesto cancelable.** Cierra al soltar pasado el umbral o con un movimiento rápido (más de unos 0.11px/ms); si no, regresa.
- **Fricción y un solo dedo.** Arrastrar hacia arriba encuentra resistencia creciente, y un segundo toque durante el arrastre se ignora.
- Todo lo que hace un arrastre también tiene un botón: el de cierre, Escape y el foco que vuelve al disparador.
- **Deshacer antes que confirmar.** Para una acción reversible, ejecútala y ofrece Deshacer en un Toast; deja Dialog para lo que no se puede deshacer.
Abrir el archivo de reglas completo (se abre en otra pestaña)

Código

components/ui/sheet.tsx
"use client"

import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"

import { cn } from "@/lib/utils"
import { CloseButton } from "@/registry/ssntl/ui/close-button"

// A bottom sheet: a modal (Radix Dialog) that slides up from the edge, with
// the same focus trap, Escape and close button as Dialog. Drag the handle
// down to dismiss it:
// - Responsive gesture: the sheet follows the pointer 1:1 from the first
//   pixel; dragging up meets resistance.
// - Continuous gesture: the pointer is captured, so straying off the handle
//   keeps the drag alive.
// - Anticipation: past the threshold the handle stretches and says
//   "Release to close", so you know what letting go will do.
// - Cancelable gesture: it only closes on release past the threshold, or on
//   a quick flick (momentum: faster than 0.11px/ms on average); letting go
//   short of both springs back.
// - Friction, not walls: dragging up meets rising resistance.
// - One finger at a time: a second touch during a drag is ignored, so the
//   sheet never jumps.
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close

function SheetContent({
  className,
  children,
  closeLabel = "Close panel",
  releaseLabel = "Release to close",
  ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
  closeLabel?: string
  /** Shown past the drag threshold. */
  releaseLabel?: string
}) {
  const sheet = React.useRef<HTMLDivElement>(null)
  const close = React.useRef<HTMLButtonElement>(null)
  const drag = React.useRef<{
    id: number
    y: number
    t: number
    dy: number
  } | null>(null)
  const [armed, setArmed] = React.useState(false)

  const threshold = () =>
    Math.min(120, (sheet.current?.offsetHeight ?? 400) * 0.3)

  const move = (dy: number) => {
    const el = sheet.current
    if (!el) return
    // Down follows 1:1; up gives less the further you pull.
    const y = dy > 0 ? dy : -Math.sqrt(-dy) * 2
    el.style.translate = `0 ${y}px`
    setArmed(dy > threshold())
  }

  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0 || drag.current) return
    event.currentTarget.setPointerCapture(event.pointerId)
    drag.current = {
      id: event.pointerId,
      y: event.clientY,
      t: performance.now(),
      dy: 0,
    }
    sheet.current?.style.setProperty("transition", "none")
  }
  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (drag.current?.id !== event.pointerId) return
    drag.current.dy = event.clientY - drag.current.y
    move(drag.current.dy)
  }
  const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
    const state = drag.current
    if (state?.id !== event.pointerId) return
    drag.current = null
    const el = sheet.current
    if (!state || !el) return
    const velocity = state.dy / (performance.now() - state.t)
    el.style.removeProperty("transition")
    const flick = state.dy > 8 && velocity > 0.11
    if (state.dy > threshold() || flick) {
      close.current?.click()
    } else {
      el.style.translate = "0 0"
      setArmed(false)
    }
  }

  return (
    <SheetPrimitive.Portal>
      <SheetPrimitive.Overlay
        data-slot="sheet-overlay"
        className="fixed inset-0 z-50 bg-black/40 data-[state=closed]:pop-out data-[state=open]:pop-in"
      />
      <SheetPrimitive.Content
        ref={sheet}
        data-slot="sheet-content"
        className={cn(
          "fixed inset-x-0 bottom-0 z-50 mx-auto grid max-h-[90dvh] w-full max-w-lg gap-2 overflow-y-auto overscroll-contain rounded-t-lg bg-card px-6 pb-8 text-left text-card-foreground elevation-4",
          "transition-[translate] duration-(--duration-menu) ease-out data-[state=closed]:sheet-out data-[state=open]:sheet-in",
          className
        )}
        {...props}
      >
        <div
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerUp}
          onPointerCancel={onPointerUp}
          className="-mx-6 grid cursor-grab touch-none justify-items-center gap-1 pt-3 pb-2 active:cursor-grabbing"
        >
          <span
            aria-hidden
            data-armed={armed || undefined}
            className="h-1.5 w-10 rounded-full bg-input transition-[width,background-color] duration-(--duration-fast) ease-out data-armed:w-16 data-armed:bg-foreground"
          />
          <span
            aria-live="polite"
            className={cn(
              "text-caption text-muted-foreground transition-opacity duration-(--duration-fast)",
              armed ? "opacity-100" : "opacity-0"
            )}
          >
            {armed ? releaseLabel : ""}
          </span>
        </div>
        {children}
        <SheetPrimitive.Close asChild>
          <CloseButton
            ref={close}
            label={closeLabel}
            className="absolute end-2 top-2"
          />
        </SheetPrimitive.Close>
      </SheetPrimitive.Content>
    </SheetPrimitive.Portal>
  )
}

function SheetTitle({
  className,
  ...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
  return (
    <SheetPrimitive.Title
      data-slot="sheet-title"
      className={cn("pe-12 text-heading", className)}
      {...props}
    />
  )
}

function SheetDescription({
  className,
  ...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
  return (
    <SheetPrimitive.Description
      data-slot="sheet-description"
      className={cn("text-body text-pretty text-muted-foreground", className)}
      {...props}
    />
  )
}

export {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetTitle,
  SheetTrigger,
}