Sheet

A panel that slides in from an edge of the screen, built on the dialog's focus trap and scroll lock.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add sheet
pnpm dlx ermineui add sheet
yarn dlx ermineui add sheet
bunx ermineui add sheet

That copies 9 files into src/components/ui/sheet/, 3 helpers into src/lib/ermine/, and the 1 component it is built from. Files you already have are kept.

Use

import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/ui/sheet'
<Sheet>
  <SheetTrigger as-child><Button>Filters</Button></SheetTrigger>
  <SheetContent side="left">
    <SheetHeader><SheetTitle>Filters</SheetTitle></SheetHeader>
  </SheetContent>
</Sheet>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
Sheet.vuesrc/components/ui/sheet/Sheet.vue
SheetClose.vuesrc/components/ui/sheet/SheetClose.vue
SheetContent.vuesrc/components/ui/sheet/SheetContent.vue
SheetDescription.vuesrc/components/ui/sheet/SheetDescription.vue
SheetFooter.vuesrc/components/ui/sheet/SheetFooter.vue
SheetHeader.vuesrc/components/ui/sheet/SheetHeader.vue
SheetTitle.vuesrc/components/ui/sheet/SheetTitle.vue
SheetTrigger.vuesrc/components/ui/sheet/SheetTrigger.vue
index.tssrc/components/ui/sheet/index.ts
layer.ts helpersrc/lib/ermine/layer.ts
primitive.ts helpersrc/lib/ermine/primitive.ts
scroll-lock.ts helpersrc/lib/ermine/scroll-lock.ts

It is built from: Dialog. These are added with it.

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

Sheet.vue 13 lines
<script setup lang="ts">
import { Dialog } from '../dialog'

const open = defineModel<boolean>('open', { default: false })
</script>

<template>
  <!-- A sheet is a dialog that sits at an edge, so it shares the dialog's state and its parts. -->
  <Dialog v-slot="slotProps" v-model:open="open">
    <slot v-bind="slotProps" />
  </Dialog>
</template>
SheetClose.vue 17 lines
<script setup lang="ts">
import type { Component } from 'vue'
import { DialogClose } from '../dialog'

withDefaults(
  defineProps<{
    as?: string | Component
    asChild?: boolean
  }>(),
  { as: 'button' },
)
</script>

<template>
  <DialogClose :as="as" :as-child="asChild"><slot /></DialogClose>
</template>
SheetContent.vue 225 lines
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useLayer } from '../../lib/layer'
import { Portal } from '../../lib/primitive'
import { lockScroll } from '../../lib/scroll-lock'
import { useDialog } from '../dialog/context'

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    /** The edge of the screen the sheet slides in from. */
    side?: 'top' | 'right' | 'bottom' | 'left'
    /** Show the × button in the corner. */
    showClose?: boolean
    /** Close when the user presses outside the sheet. */
    closeOnOutside?: boolean
    /** Accessible name of the × button. */
    closeLabel?: string
  }>(),
  { side: 'right', showClose: true, closeOnOutside: true, closeLabel: 'Close' },
)

const sheet = useDialog()
const content = ref<HTMLElement | null>(null)

useLayer(content, sheet.open, {
  modal: true,
  loopFocus: true,
  onDismiss: (reason) => {
    if (reason === 'outside' && !props.closeOnOutside) return
    sheet.setOpen(false)
  },
})

watch(
  sheet.open,
  (open, _previous, onCleanup) => {
    if (open) onCleanup(lockScroll())
  },
  { immediate: true },
)
</script>

<template>
  <Portal>
    <Transition name="er-sheet">
      <div v-if="sheet.open.value" class="er-sheet-overlay">
        <div
          ref="content"
          v-bind="$attrs"
          role="dialog"
          aria-modal="true"
          tabindex="-1"
          class="er-sheet-content"
          :data-side="side"
          :aria-labelledby="sheet.titleId"
          :aria-describedby="sheet.hasDescription.value ? sheet.descriptionId : undefined"
        >
          <slot />
          <button v-if="showClose" type="button" class="er-sheet-x" :aria-label="closeLabel" @click="sheet.setOpen(false)">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
              <path d="M18 6 6 18M6 6l12 12" />
            </svg>
          </button>
        </div>
      </div>
    </Transition>
  </Portal>
</template>

<style>
@layer components {
  .er-sheet-overlay {
    position: fixed;
    inset: 0;
    z-index: var(--er-z-overlay);
    background: var(--er-overlay);
    overflow: hidden;
  }

  .er-sheet-content {
    position: absolute;
    display: flex;
    flex-direction: column;
    gap: 1rem;
    padding: 1.5rem;
    background: var(--er-background);
    color: var(--er-foreground);
    box-shadow: var(--er-shadow-lg);
    outline: none;
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  .er-sheet-content[data-side='right'],
  .er-sheet-content[data-side='left'] {
    top: 0;
    bottom: 0;
    width: min(24rem, calc(100% - 3rem));
  }

  .er-sheet-content[data-side='right'] {
    right: 0;
    border-left: 1px solid var(--er-border);
  }

  .er-sheet-content[data-side='left'] {
    left: 0;
    border-right: 1px solid var(--er-border);
  }

  .er-sheet-content[data-side='top'],
  .er-sheet-content[data-side='bottom'] {
    right: 0;
    left: 0;
    max-height: calc(100% - 3rem);
  }

  .er-sheet-content[data-side='top'] {
    top: 0;
    border-bottom: 1px solid var(--er-border);
  }

  .er-sheet-content[data-side='bottom'] {
    bottom: 0;
    border-top: 1px solid var(--er-border);
  }

  .er-sheet-header {
    display: grid;
    gap: 0.5rem;
    padding-right: 1.5rem;
  }

  .er-sheet-title {
    margin: 0;
    font-size: 1.125rem;
    font-weight: var(--er-weight-semibold);
    line-height: 1.2;
  }

  .er-sheet-description {
    margin: 0;
    color: var(--er-muted-foreground);
    font-size: 0.875rem;
    line-height: 1.5;
  }

  /* Pushed to the far end, so actions sit at the bottom of a tall side sheet. */
  .er-sheet-footer {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: 0.5rem;
    margin-top: auto;
  }

  .er-sheet-x {
    position: absolute;
    top: 1rem;
    right: 1rem;
    display: grid;
    place-content: center;
    width: 1.5rem;
    height: 1.5rem;
    padding: 0;
    border: 0;
    border-radius: var(--er-radius-sm);
    background: transparent;
    color: var(--er-muted-foreground);
    cursor: pointer;
    transition: color var(--er-duration);
  }

  .er-sheet-x:hover {
    color: var(--er-foreground);
  }

  .er-sheet-x:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 2px;
  }

  .er-sheet-x svg {
    width: 1rem;
    height: 1rem;
  }

  .er-sheet-enter-active,
  .er-sheet-leave-active,
  .er-sheet-enter-active .er-sheet-content,
  .er-sheet-leave-active .er-sheet-content {
    transition:
      opacity calc(var(--er-duration) * 1.5) var(--er-ease),
      transform calc(var(--er-duration) * 1.5) var(--er-ease);
  }

  .er-sheet-enter-from,
  .er-sheet-leave-to {
    opacity: 0;
  }

  .er-sheet-enter-from .er-sheet-content[data-side='right'],
  .er-sheet-leave-to .er-sheet-content[data-side='right'] {
    transform: translateX(100%);
  }

  .er-sheet-enter-from .er-sheet-content[data-side='left'],
  .er-sheet-leave-to .er-sheet-content[data-side='left'] {
    transform: translateX(-100%);
  }

  .er-sheet-enter-from .er-sheet-content[data-side='top'],
  .er-sheet-leave-to .er-sheet-content[data-side='top'] {
    transform: translateY(-100%);
  }

  .er-sheet-enter-from .er-sheet-content[data-side='bottom'],
  .er-sheet-leave-to .er-sheet-content[data-side='bottom'] {
    transform: translateY(100%);
  }
}
</style>
SheetDescription.vue 18 lines
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { useDialog } from '../dialog/context'

const sheet = useDialog()

onMounted(() => {
  sheet.hasDescription.value = true
})
onBeforeUnmount(() => {
  sheet.hasDescription.value = false
})
</script>

<template>
  <p :id="sheet.descriptionId" class="er-sheet-description"><slot /></p>
</template>
SheetFooter.vue 4 lines
<template>
  <div class="er-sheet-footer"><slot /></div>
</template>
SheetHeader.vue 4 lines
<template>
  <div class="er-sheet-header"><slot /></div>
</template>
SheetTitle.vue 10 lines
<script setup lang="ts">
import { useDialog } from '../dialog/context'

const sheet = useDialog()
</script>

<template>
  <h2 :id="sheet.titleId" class="er-sheet-title"><slot /></h2>
</template>
SheetTrigger.vue 18 lines
<script setup lang="ts">
import type { Component } from 'vue'
import { DialogTrigger } from '../dialog'

withDefaults(
  defineProps<{
    as?: string | Component
    /** Use the single child (e.g. a Button) as the trigger instead of rendering one. */
    asChild?: boolean
  }>(),
  { as: 'button' },
)
</script>

<template>
  <DialogTrigger :as="as" :as-child="asChild"><slot /></DialogTrigger>
</template>
index.ts 10 lines
/** Sheet — a panel that slides in from an edge of the screen, built on the dialog's focus trap and scroll lock. */
export { default as Sheet } from './Sheet.vue'
export { default as SheetClose } from './SheetClose.vue'
export { default as SheetContent } from './SheetContent.vue'
export { default as SheetDescription } from './SheetDescription.vue'
export { default as SheetFooter } from './SheetFooter.vue'
export { default as SheetHeader } from './SheetHeader.vue'
export { default as SheetTitle } from './SheetTitle.vue'
export { default as SheetTrigger } from './SheetTrigger.vue'