Sheet
sheetuiSide-mounted modal that slides in from the top, right, bottom, or left edge. Use for filter panels, edit drawers, and mobile menus.
When to use which
Decision GuideDialogs capture immediate user focus for critical actions. Sheets slide in from screen edges for deep secondary forms, while Popovers provide lightweight contextual cards.
| Component | Role | Use when |
|---|---|---|
| Dialog Critical confirmations, destructive warnings, or focused single-purpose tasks requiring user resolution. | Modal Window | |
| Sheetcurrent Complex edit forms, filter panes, or shopping carts that retain underlying page context. | Off-Canvas Slide-Over | |
| Drawer Mobile bottom sheets, touch-friendly sliding panels, or secondary inspection trays. | Edge Panel / Bottom Sheet | |
| Popover Small contextual dropdowns, color pickers, date pickers, or inline configuration flyouts. | Contextual Floating Card |
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/sheet.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/sheet.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/sheet.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/sheet.jsonhttps://uipkge.dev/r/svelte/sheet.jsonInstalls to:src/lib/components/ui/sheet/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
open | boolean | — | optional |
modalbody scroll-lock, and autofocus, and the overlay stops intercepting pointer events so the page stays interactive (Radix `modal` parity). | boolean | true | optional |
onOpenChange | (open: boolean) => void | — | optional |
children | Snippet | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
SheetContextValueinterface SheetContextValue {
readonly open: boolean
setOpen: (open: boolean) => void
readonly titleId: string | undefined
setTitleId: (id: string | undefined) => void
readonly descriptionId: string | undefined
setDescriptionId: (id: string | undefined) => void
readonly modal: boolean
}npm dependencies
Used by
Files installed (11)
src/lib/components/ui/sheet/Sheet.svelte1.2 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface SheetProps { open?: boolean /** When false the sheet is non-modal: content skips the focus trap, * body scroll-lock, and autofocus, and the overlay stops intercepting * pointer events so the page stays interactive (Radix `modal` parity). */ modal?: boolean onOpenChange?: (open: boolean) => void children?: Snippet } </script> <script lang="ts"> import { setSheetContext } from './context' let { open = $bindable(false), modal = true, onOpenChange, children }: SheetProps = $props() let titleId: string | undefined = $state(undefined) let descriptionId: string | undefined = $state(undefined) function setOpen(next: boolean) { if (next === open) return open = next onOpenChange?.(next) } // Getters over $state keep every consumer reactive without runes in context.ts. setSheetContext({ get open() { return open }, setOpen, get titleId() { return titleId }, setTitleId: (id) => (titleId = id), get descriptionId() { return descriptionId }, setDescriptionId: (id) => (descriptionId = id), get modal() { return modal }, }) </script> {@render children?.()}src/lib/components/ui/sheet/SheetClose.svelte1.1 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLButtonAttributes } from 'svelte/elements' export interface SheetCloseProps extends HTMLButtonAttributes { /** Render your own element that closes the sheet — spread `props` onto it. */ child?: Snippet<[{ props: Record<string, unknown> }]> ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getSheetContext } from './context' let { class: className, child, children, ref = $bindable(null), onclick, ...restProps }: SheetCloseProps = $props() const ctx = getSheetContext() function handleClick(e: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) { ctx?.setOpen(false) onclick?.(e) } const mergedProps = $derived({ 'data-uipkge': '', 'data-slot': 'sheet-close', class: cn(className), onclick: handleClick, ...restProps, }) </script> {#if child} {@render child({ props: mergedProps })} {:else} <button bind:this={ref} type="button" {...mergedProps}>{@render children?.()}</button> {/if}src/lib/components/ui/sheet/SheetContent.svelte4.4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface SheetContentProps extends HTMLAttributes<HTMLDivElement> { side?: 'top' | 'right' | 'bottom' | 'left' ref?: HTMLDivElement | null } </script> <script lang="ts"> import { X } from '@lucide/svelte' import { cn } from '$lib/utils' import { getSheetContext } from './context' import SheetOverlay from './SheetOverlay.svelte' let { class: className, side = 'right', children, ref = $bindable(null), ...restProps }: SheetContentProps = $props() const ctx = getSheetContext() const open = $derived(ctx?.open ?? false) let panel: HTMLDivElement | null = $state(null) $effect(() => { ref = panel }) // Hand-rolled dialog behavior (no headless dep yet — see the bits-ui // proposal in the port report): Escape to close, body scroll lock, // autofocus + focus trap while open, and focus restore on close. $effect(() => { if (!open || !ctx) return const node = panel if (!node || typeof document === 'undefined') return const previousOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' const previouslyFocused = document.activeElement as HTMLElement | null const focusables = () => [...node.querySelectorAll<HTMLElement>('a[href], button, input, select, textarea, [tabindex]')].filter( (el) => !el.hasAttribute('disabled') && el.tabIndex !== -1 && el.offsetParent !== null, ) // Focus the first control (or the panel itself) so keyboard users land inside. const first = focusables()[0] if (first) first.focus() else { node.tabIndex = -1 node.focus({ preventScroll: true }) } const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() ctx.setOpen(false) return } if (e.key !== 'Tab') return const items = focusables() if (items.length === 0) { e.preventDefault() return } const firstItem = items[0]! const lastItem = items[items.length - 1]! if (e.shiftKey && document.activeElement === firstItem) { e.preventDefault() lastItem.focus() } else if (!e.shiftKey && document.activeElement === lastItem) { e.preventDefault() firstItem.focus() } } document.addEventListener('keydown', onKeyDown) return () => { document.removeEventListener('keydown', onKeyDown) document.body.style.overflow = previousOverflow previouslyFocused?.focus?.({ preventScroll: true }) } }) </script> {#if open} <SheetOverlay /> <div bind:this={panel} role="dialog" aria-modal="true" aria-labelledby={ctx?.titleId} aria-describedby={ctx?.descriptionId} data-uipkge="" data-slot="sheet-content" data-state="open" data-side={side} class={cn( 'bg-background motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:ease-emphasized motion-safe:data-[state=open]:blur-in-2 motion-safe:data-[state=closed]:blur-out-2 fixed z-50 flex flex-col gap-4 overflow-hidden shadow-lg transition ease-in-out motion-safe:data-[state=closed]:duration-200 motion-safe:data-[state=open]:duration-300', side === 'right' && 'motion-safe:data-[state=closed]:slide-out-to-right motion-safe:data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm', side === 'left' && 'motion-safe:data-[state=closed]:slide-out-to-left motion-safe:data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', side === 'top' && 'motion-safe:data-[state=closed]:slide-out-to-top motion-safe:data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b', side === 'bottom' && 'motion-safe:data-[state=closed]:slide-out-to-bottom motion-safe:data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t', className, )} {...restProps} > {@render children?.()} <button type="button" onclick={() => ctx?.setOpen(false)} class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none" > <X class="size-4" aria-hidden="true" /> <span class="sr-only">Close</span> </button> </div> {/if}src/lib/components/ui/sheet/SheetDescription.svelte0.9 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface SheetDescriptionProps extends HTMLAttributes<HTMLParagraphElement> { ref?: HTMLParagraphElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getSheetContext, nextSheetId } from './context' let { class: className, id, children, ref = $bindable(null), ...restProps }: SheetDescriptionProps = $props() const ctx = getSheetContext() const descriptionId = $derived(id ?? nextSheetId('sheet-description')) $effect(() => { ctx?.setDescriptionId(descriptionId) return () => ctx?.setDescriptionId(undefined) }) </script> <p bind:this={ref} id={descriptionId} data-uipkge="" data-slot="sheet-description" class={cn('text-muted-foreground text-sm', className)} {...restProps} > {@render children?.()} </p>src/lib/components/ui/sheet/SheetFooter.svelte0.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface SheetFooterProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: SheetFooterProps = $props() </script> <div bind:this={ref} data-uipkge="" data-slot="sheet-footer" class={cn('mt-auto flex flex-col gap-2 p-4', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/sheet/SheetHeader.svelte0.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface SheetHeaderProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: SheetHeaderProps = $props() </script> <div bind:this={ref} data-uipkge="" data-slot="sheet-header" class={cn('flex flex-col gap-1.5 p-4', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/sheet/SheetOverlay.svelte1.1 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface SheetOverlayProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getSheetContext } from './context' import { portal } from './portal' let { class: className, ref = $bindable(null), onclick, ...restProps }: SheetOverlayProps = $props() const ctx = getSheetContext() function handleClick(e: MouseEvent & { currentTarget: EventTarget & HTMLDivElement }) { ctx?.setOpen(false) onclick?.(e) } </script> <div use:portal bind:this={ref} data-uipkge="" data-slot="sheet-overlay" data-state={ctx?.open ? 'open' : 'closed'} class={cn( 'motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:fade-in-0 bg-foreground/50 fixed inset-0 z-50', ctx?.modal === false && 'pointer-events-none opacity-0', className, )} onclick={handleClick} {...restProps} ></div>src/lib/components/ui/sheet/SheetTitle.svelte0.8 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface SheetTitleProps extends HTMLAttributes<HTMLHeadingElement> { ref?: HTMLHeadingElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getSheetContext, nextSheetId } from './context' let { class: className, id, children, ref = $bindable(null), ...restProps }: SheetTitleProps = $props() const ctx = getSheetContext() const titleId = $derived(id ?? nextSheetId('sheet-title')) $effect(() => { ctx?.setTitleId(titleId) return () => ctx?.setTitleId(undefined) }) </script> <h2 bind:this={ref} id={titleId} data-uipkge="" data-slot="sheet-title" class={cn('text-foreground font-semibold', className)} {...restProps} > {@render children?.()} </h2>src/lib/components/ui/sheet/SheetTrigger.svelte1.1 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLButtonAttributes } from 'svelte/elements' export interface SheetTriggerProps extends HTMLButtonAttributes { /** Render your own element that opens the sheet — spread `props` onto it. */ child?: Snippet<[{ props: Record<string, unknown> }]> ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getSheetContext } from './context' let { class: className, child, children, ref = $bindable(null), onclick, ...restProps }: SheetTriggerProps = $props() const ctx = getSheetContext() function handleClick(e: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) { ctx?.setOpen(true) onclick?.(e) } const mergedProps = $derived({ 'data-uipkge': '', 'data-slot': 'sheet-trigger', class: cn(className), onclick: handleClick, ...restProps, }) </script> {#if child} {@render child({ props: mergedProps })} {:else} <button bind:this={ref} type="button" {...mergedProps}>{@render children?.()}</button> {/if}src/lib/components/ui/sheet/context.ts0.9 kB
import { getContext, hasContext, setContext } from 'svelte' const SHEET_CONTEXT_KEY = Symbol('uipkge-sheet') export interface SheetContextValue { readonly open: boolean setOpen: (open: boolean) => void readonly titleId: string | undefined setTitleId: (id: string | undefined) => void readonly descriptionId: string | undefined setDescriptionId: (id: string | undefined) => void readonly modal: boolean } export function setSheetContext(value: SheetContextValue) { setContext(SHEET_CONTEXT_KEY, value) } export function getSheetContext(): SheetContextValue | undefined { if (!hasContext(SHEET_CONTEXT_KEY)) return undefined return getContext<SheetContextValue>(SHEET_CONTEXT_KEY) } let sheetIdCounter = 0 /** Cheap unique id for aria-labelledby wiring. Module-scoped counter is enough per document. */ export function nextSheetId(prefix: string) { sheetIdCounter += 1 return `uipkge-${prefix}-${sheetIdCounter}` }src/lib/components/ui/sheet/index.ts0.7 kB
export { default as Sheet, type SheetProps } from './Sheet.svelte' export { default as SheetClose, type SheetCloseProps } from './SheetClose.svelte' export { default as SheetContent, type SheetContentProps } from './SheetContent.svelte' export { default as SheetDescription, type SheetDescriptionProps } from './SheetDescription.svelte' export { default as SheetFooter, type SheetFooterProps } from './SheetFooter.svelte' export { default as SheetHeader, type SheetHeaderProps } from './SheetHeader.svelte' export { default as SheetTitle, type SheetTitleProps } from './SheetTitle.svelte' export { default as SheetTrigger, type SheetTriggerProps } from './SheetTrigger.svelte' export { getSheetContext, setSheetContext, type SheetContextValue } from './context'
Raw manifest:https://uipkge.dev/r/svelte/sheet.json