Lag og beskeder
Sheet
Et panel, der glider op fra bunden og kan trækkes ned for at lukke.
Forhåndsvisning
Brug
sheet-demo.tsx
import { useState } from 'react'
import { Sheet } from '@/components/sheet'
export default function SheetDemo() {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" className="min-h-11 rounded-xl border bg-card px-4 text-sm font-medium" onClick={() => setOpen(true)}>
Open delivery options
</button>
<Sheet open={open} onOpenChange={setOpen} title="Delivery options">
<p>Standard delivery takes 3 to 5 days and is free.</p>
<button
type="button"
onClick={() => setOpen(false)}
className="mt-4 min-h-11 w-full rounded-xl bg-primary text-sm font-medium text-primary-foreground"
>
Done
</button>
</Sheet>
</>
)
}Kilde
sheet.tsx
import { useEffect, useRef, type PointerEvent, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type SheetProps = {
open: boolean
onOpenChange: (open: boolean) => void
/** Accessible name, and the heading at the top of the sheet. */
title: string
children: ReactNode
className?: string
}
/**
* A panel that slides up from the bottom. Drag it down, press Esc or tap outside to close.
* Built on the native <dialog>, so focus is trapped and returns to the opener on close.
*/
export function Sheet({ open, onOpenChange, title, children, className }: SheetProps) {
const dialog = useRef<HTMLDialogElement>(null)
const drag = useRef<{ y: number; id: number } | null>(null)
useEffect(() => {
const d = dialog.current
if (!d) return
if (open && !d.open) {
d.style.transform = ''
d.showModal()
} else if (!open && d.open) d.close()
}, [open])
function down(e: PointerEvent<HTMLDivElement>) {
drag.current = { y: e.clientY, id: e.pointerId }
e.currentTarget.setPointerCapture(e.pointerId)
dialog.current!.style.transition = 'none'
}
function move(e: PointerEvent<HTMLDivElement>) {
if (!drag.current) return
dialog.current!.style.transform = `translateY(${Math.max(0, e.clientY - drag.current.y)}px)`
}
function up(e: PointerEvent<HTMLDivElement>) {
if (!drag.current) return
const d = dialog.current!
const dy = e.clientY - drag.current.y
drag.current = null
d.style.transition = ''
if (dy > d.offsetHeight * 0.3) onOpenChange(false)
else d.style.transform = ''
}
return (
<dialog
ref={dialog}
onClose={() => onOpenChange(false)}
onClick={(e) => e.target === dialog.current && onOpenChange(false)}
aria-label={title}
className={cn(
'mx-auto mt-auto mb-0 w-full max-w-xl rounded-t-3xl border border-b-0 bg-card p-0 text-card-foreground shadow-2xl',
'max-h-[85dvh] overflow-hidden transition-transform duration-300 ease-out-quint',
'backdrop:bg-black/40 open:animate-[manniche-sheet-in_380ms_cubic-bezier(0.23,1,0.32,1)] motion-reduce:open:animate-none',
className,
)}
>
<style href="manniche-sheet" precedence="default">
{'@keyframes manniche-sheet-in { from { transform: translateY(100%) } }'}
</style>
<div
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
className="cursor-grab touch-none px-6 pt-3 pb-2 active:cursor-grabbing"
>
<div className="mx-auto mb-4 h-1.5 w-11 rounded-full bg-border" aria-hidden />
<h2 className="text-lg font-medium">{title}</h2>
</div>
<div className="max-h-[calc(85dvh-5rem)] overflow-y-auto px-6 pb-[max(2rem,env(safe-area-inset-bottom))]">{children}</div>
</dialog>
)
}Installér
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/sheet.jsonMed registret tilføjet (components.json)
npx shadcn@latest add @manniche/sheet