Skip to content

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.

Preview Live · Vue
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>
jsx
;<>
  <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>
View source Sheet.vue
Sheet.vue
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.

Terminal
npx klean-ui add sheet

Source you own. Edit the installed files to make them yours.

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 ​

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 ​

PurposeVueReactSvelte
Native targetididid
Open statev-model:openopen, onOpenChangebind:open
Initial statedefault-opendefaultOpendefaultOpen
Ambient dismissaldismissibledismissibledismissible
StylingclassclassNameclass
Native capabilitycomponent refHTMLDialogElement refcomponent 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.

Preview Live · Vue
View source MobileNavigation.vue
MobileNavigation.vue
<template>
  <aside class="hidden w-64 border-r lg:block">
    <nav aria-label="Project"><!-- desktop navigation --></nav>
  </aside>

  <Button commandfor="mobile-navigation" command="show-modal" class="lg:hidden">
    Open navigation
  </Button>

  <Sheet
    id="mobile-navigation"
    aria-labelledby="mobile-navigation-title"
    class="right-auto left-0 mr-auto ml-0 w-72 -translate-x-full border-r border-l-0 open:translate-x-0 starting:open:-translate-x-full"
  >
    <header class="flex items-center justify-between p-4">
      <h2 id="mobile-navigation-title">Slipway</h2>
      <Button
        commandfor="mobile-navigation"
        command="request-close"
        autofocus
        aria-label="Close navigation"
      >
        ×
      </Button>
    </header>
    <nav aria-label="Project"><!-- mobile navigation --></nav>
  </Sheet>
</template>

Mobile comments ​

Use a bottom Sheet for an invoice conversation. Keep the thread, draft, submission, and comment count in application markup and state.

Preview Live · Vue
View source InvoiceComments.vue
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-dvh follows 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-motion removes 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.

  • 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 ​

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>
jsx
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>

All open source projects are released under the MIT License.