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.

Sheet.vue

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.

Terminal
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

ProjectDetails.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

ProjectDetails.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

ProjectDetails.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

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.

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.

MobileNavigation.vue

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.

InvoiceComments.vue

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 application navigation; its future mobile recipe can compose Sheet.

Complete framework source

Vue

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>

React

Sheet.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

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.