Sheet
Sheet is one native off-canvas dialog. It gives a mobile navigation drawer, comments thread, inspector, or focused form the same reliable modal behavior without deciding what the application puts inside it.
The neutral default enters from the right. Ordinary Tailwind moves that same component to the left or bottom. There is no side, size, tone, or product variant API.
Installation
One command installs Dialog when it is missing, then copies the detected framework's Sheet source into the conventional component directory:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add sheet- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
There is no provider, portal, focus-trap dependency, configuration file, placement prop, or Klean runtime.
When to use
Use Sheet for a modal surface attached to a viewport edge: mobile navigation, comments on a narrow screen, contextual details, filters that need room, or a focused supporting workflow that should not replace the current page.
When not to use
Use Dialog for a centered task or consequential confirmation. Use Popover for an anchored non-modal surface. A persistent desktop navigation rail is ordinary <aside> and <nav> layout—not Sheet.
Usage
Vue
<Button commandfor="project-details" command="show-modal">
Open details
</Button>
<Sheet id="project-details" aria-labelledby="project-details-title">
<article class="grid h-full grid-rows-[auto_minmax(0,1fr)_auto]">
<header class="flex items-center justify-between border-b p-5">
<h2 id="project-details-title">Project details</h2>
<Button
commandfor="project-details"
command="request-close"
autofocus
aria-label="Close project details"
>
×
</Button>
</header>
<div class="overflow-y-auto p-5">Your application content.</div>
</article>
</Sheet>
React
;<>
<Button commandfor="project-details" command="show-modal">
Open details
</Button>
<Sheet id="project-details" aria-labelledby="project-details-title">
<article className="grid h-full grid-rows-[auto_minmax(0,1fr)_auto]">
<header className="flex items-center justify-between border-b p-5">
<h2 id="project-details-title">Project details</h2>
<Button
commandfor="project-details"
command="request-close"
autoFocus
aria-label="Close project details"
>
×
</Button>
</header>
<div className="overflow-y-auto p-5">Your application content.</div>
</article>
</Sheet>
</>
Svelte
<Button commandfor="project-details" command="show-modal">Open details</Button>
<Sheet id="project-details" aria-labelledby="project-details-title">
<article class="grid h-full grid-rows-[auto_minmax(0,1fr)_auto]">
<header class="flex items-center justify-between border-b p-5">
<h2 id="project-details-title">Project details</h2>
<Button
commandfor="project-details"
command="request-close"
autofocus
aria-label="Close project details"
>
×
</Button>
</header>
<div class="overflow-y-auto p-5">Your application content.</div>
</article>
</Sheet>
The application supplies the heading, description, scroll region, actions, and semantic content. Sheet supplies only the native modal and off-canvas contract.
API
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Native target | id | id | id |
| Open state | v-model:open | open, onOpenChange | bind:open |
| Initial state | default-open | defaultOpen | defaultOpen |
| Ambient dismissal | dismissible | dismissible | dismissible |
| Styling | class | className | class |
| Native capability | component ref | HTMLDialogElement ref | component binding |
Vue and Svelte expose showModal(), close(returnValue), and requestClose(returnValue). React forwards the native Dialog ref. Prefer a real Button with commandfor and command, plus native <form method="dialog">, for ordinary opening and completion.
Every Sheet needs an accessible name. Point aria-labelledby at its visible heading or provide aria-label. Add aria-describedby only when a short description helps explain the surface.
Native and durable behavior
Sheet composes Klean Dialog rather than duplicating its behavioral engine. The browser puts the native <dialog> in the top layer, makes the background inert, contains focus, and handles Escape. Klean preserves controlled or uncontrolled state, backdrop policy, invoker focus return, scroll restoration, and clean unmounting.
The open state is normally ephemeral. A route can decide whether the mobile navigation is open, but navigation history should represent the destination, not every drawer animation. Drafts and filters inside a Sheet remain caller-owned state and follow the same persistence rules they would anywhere else.
Placement is Tailwind
The right edge is a useful neutral default. Move the component without a placement prop:
- Left: replace right alignment with
left-0, change the border edge, and use negative x translation. - Bottom: use
bottom-0, full width, auto height, and y translation. - Responsive: use Tailwind breakpoints to move or resize the same surface.
The complete transform must cover both the closed and starting:open states so entry and exit originate from the same edge. Caller classes win through tailwind-merge.
Slipway mobile navigation
Slipway's desktop rail stays in normal document layout. Only its narrow-screen presentation is a Sheet. Both presentations can render the same application-owned links, permissions, active state, and team context.
Hagfish mobile comments
Hagfish uses the same contract as a bottom Sheet. The invoice thread, draft, submission, and comment count remain ordinary Hagfish markup and state.
Viewport, scrolling, and motion
h-dvhfollows the dynamic mobile viewport for the default full-height frame.- Put scrolling on the content region, not the whole document.
- Bottom actions can use
env(safe-area-inset-bottom)through ordinary Tailwind arbitrary values. - Entry and exit use discrete display and overlay transitions with a short x-axis default.
prefers-reduced-motionremoves the transition automatically.- Swipe-to-dismiss is not implied. Add gestures only when the application proves they do not conflict with scrolling, selection, or assistive technology.
Styling
class or className merges after the neutral right-side frame. The component also exposes data-klean-sheet, inherited data-slot="dialog", and data-state="open|closed" for application-owned selectors. Repeated product treatments belong in a local wrapper or copied source, not a variant prop.
Related components
- Dialog — the native modal contract Sheet composes.
- Popover — an anchored non-modal surface.
- Menu — compact actions or destinations.
- Button — native commands for opening and closing.
- Sidebar — persistent application navigation; its future mobile recipe can compose Sheet.
Complete framework source
Vue
<script setup>
import { computed, ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'
import Dialog from '../dialog/Dialog.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps({
/** The native id targeted by a button's `commandfor` attribute. */
id: { type: String, default: undefined },
/** Framework-native controlled state. Omit for native uncontrolled use. */
open: { type: Boolean, default: undefined },
/** Initial state when `open` is not controlled. */
defaultOpen: { type: Boolean, default: false },
/** Whether Escape, platform dismissal, and backdrop clicks may close it. */
dismissible: { type: Boolean, default: true }
})
const emit = defineEmits(['update:open'])
const attrs = useAttrs()
const sheet = ref()
const BASE_CLASSES = [
'fixed inset-y-0 right-0 left-auto m-0 ml-auto h-dvh max-h-none w-[min(26rem,calc(100vw-1rem))] max-w-none translate-x-full overflow-hidden rounded-none border-y-0 border-r-0 border-l border-gray-200 bg-white p-0 text-gray-950 opacity-0 shadow-2xl outline-none',
'open:translate-x-0 open:opacity-100',
'transition-[display,overlay,opacity,transform] transition-discrete duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]',
'starting:open:translate-x-full starting:open:opacity-0 motion-reduce:transition-none',
'backdrop:bg-black/50 starting:open:backdrop:bg-black/0',
'dark:border-gray-700 dark:bg-gray-950 dark:text-white'
]
const sheetAttrs = computed(() => {
const { class: _class, ...rest } = attrs
return rest
})
const sheetClasses = computed(() => twMerge(BASE_CLASSES, attrs.class))
function showModal(source) {
sheet.value?.showModal(source)
}
function close(returnValue) {
sheet.value?.close(returnValue)
}
function requestClose(returnValue) {
sheet.value?.requestClose(returnValue)
}
defineExpose({ sheet, showModal, close, requestClose })
</script>
<template>
<Dialog
ref="sheet"
v-bind="sheetAttrs"
:id="props.id"
:open="props.open"
:default-open="props.defaultOpen"
:dismissible="props.dismissible"
data-klean-sheet=""
:class="sheetClasses"
@update:open="emit('update:open', $event)"
>
<slot />
</Dialog>
</template>
React
import { forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'
import Dialog from '../dialog/Dialog.jsx'
const BASE_CLASSES = [
'fixed inset-y-0 right-0 left-auto m-0 ml-auto h-dvh max-h-none w-[min(26rem,calc(100vw-1rem))] max-w-none translate-x-full overflow-hidden rounded-none border-y-0 border-r-0 border-l border-gray-200 bg-white p-0 text-gray-950 opacity-0 shadow-2xl outline-none',
'open:translate-x-0 open:opacity-100',
'transition-[display,overlay,opacity,transform] transition-discrete duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]',
'starting:open:translate-x-full starting:open:opacity-0 motion-reduce:transition-none',
'backdrop:bg-black/50 starting:open:backdrop:bg-black/0',
'dark:border-gray-700 dark:bg-gray-950 dark:text-white'
]
const Sheet = forwardRef(function Sheet(
{ className, children, ...dialogProps },
forwardedRef
) {
return (
<Dialog
{...dialogProps}
ref={forwardedRef}
data-klean-sheet=""
className={twMerge(BASE_CLASSES, className)}
>
{children}
</Dialog>
)
})
export default Sheet
Svelte
<script>
import { twMerge } from "tailwind-merge";
import Dialog from "../dialog/Dialog.svelte";
const BASE_CLASSES = [
"fixed inset-y-0 right-0 left-auto m-0 ml-auto h-dvh max-h-none w-[min(26rem,calc(100vw-1rem))] max-w-none translate-x-full overflow-hidden rounded-none border-y-0 border-r-0 border-l border-gray-200 bg-white p-0 text-gray-950 opacity-0 shadow-2xl outline-none",
"open:translate-x-0 open:opacity-100",
"transition-[display,overlay,opacity,transform] transition-discrete duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]",
"starting:open:translate-x-full starting:open:opacity-0 motion-reduce:transition-none",
"backdrop:bg-black/50 starting:open:backdrop:bg-black/0",
"dark:border-gray-700 dark:bg-gray-950 dark:text-white",
];
let {
open = $bindable(),
defaultOpen = false,
dismissible = true,
onOpenChange,
class: className = "",
children,
...dialogProps
} = $props();
let sheet;
export function showModal(source) {
sheet?.showModal(source);
}
export function close(returnValue) {
sheet?.close(returnValue);
}
export function requestClose(returnValue) {
sheet?.requestClose(returnValue);
}
</script>
<Dialog
{...dialogProps}
bind:this={sheet}
bind:open
{defaultOpen}
{dismissible}
{onOpenChange}
data-klean-sheet=""
class={twMerge(BASE_CLASSES, className)}
>
{@render children?.()}
</Dialog>