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.
<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>;<>
<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>
</><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>View source Sheet.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>Installation
One command installs Dialog when it is missing, then copies the detected framework's Sheet source into the conventional component directory:
Run from your application’s directory. Klean detects your framework and copies the matching source.
npx klean-ui add sheetWhen 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
Copy the example above for your framework.
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.
Mobile navigation
Keep a desktop rail 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.
View source MobileNavigation.vue
Mobile comments
Use a bottom Sheet for an invoice conversation. Keep the thread, draft, submission, and comment count in application markup and state.
View source InvoiceComments.vue
<Button commandfor="invoice-comments" command="show-modal">
Open comments
</Button>
<Sheet
id="invoice-comments"
aria-labelledby="invoice-comments-title"
class="inset-x-0 top-auto bottom-0 m-0 mt-auto h-auto max-h-[70dvh] w-full max-w-none translate-x-0 translate-y-full rounded-t-2xl border-x-0 border-b-0 border-t open:translate-y-0 starting:open:translate-x-0 starting:open:translate-y-full"
>
<header class="flex items-center justify-between px-5 py-4">
<h2 id="invoice-comments-title">Comments</h2>
<Button
commandfor="invoice-comments"
command="request-close"
autofocus
aria-label="Close comments"
>
×
</Button>
</header>
<section
aria-label="Invoice discussion"
class="max-h-[50dvh] overflow-y-auto px-5 pb-[max(1.25rem,env(safe-area-inset-bottom))]"
>
<!-- comment thread and form -->
</section>
</Sheet>
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 navigation for wider layouts; use Sheet for a modal mobile drawer.
Complete framework source
<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>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<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>