Frappe UIFrappe UI

BottomSheet

A panel that slides up from the bottom edge and holds the screen until it is dismissed. The mobile counterpart to Dialog — same modal semantics, reached by thumb instead of by pointer.

Built on reka-ui's Dialog primitives, so the focus trap, scroll lock and Escape handling come for free. On top of that it adds a grab handle and swipe-down-to-dismiss.

Basic

Bind v-model:open. The sheet sizes itself to its content up to 90% of the viewport height, then scrolls inside.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { BottomSheet, Button } from 'frappe-ui'

const open = ref(false)
</script>

<template>
  <Button label="Open sheet" @click="open = true" />

  <BottomSheet v-model:open="open">
    <div class="p-4 pb-8 text-p-base text-ink-gray-7">
      Drag the handle down, tap outside, or press
      <kbd>Esc</kbd> to dismiss.
    </div>
  </BottomSheet>
</template>

Title

title renders a centered heading in the grab-handle area and labels the sheet for screen readers. Without it the sheet is labelled "Bottom sheet".

vue
<script setup lang="ts">
import { ref } from 'vue'
import { BottomSheet, Button } from 'frappe-ui'

const open = ref(false)
const options = ['Rename', 'Duplicate', 'Move to folder', 'Delete']
</script>

<template>
  <Button label="Open with a title" @click="open = true" />

  <BottomSheet v-model:open="open" title="Actions">
    <div class="flex flex-col p-2 pb-8">
      <button
        v-for="option in options"
        :key="option"
        class="rounded-4 px-3 py-2.5 text-start text-p-base text-ink-gray-8 hover:bg-surface-gray-2"
        @click="open = false"
      >
        {{ option }}
      </button>
    </div>
  </BottomSheet>
</template>

Non-dismissible

dismissible defaults to true. Set it to false to turn off all three dismiss channels at once — outside click, Escape, and swipe-down — for a sheet that has to be answered.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { BottomSheet, Button } from 'frappe-ui'

const open = ref(false)
</script>

<template>
  <Button label="Open a sheet you must answer" @click="open = true" />

  <BottomSheet v-model:open="open" title="Discard draft?" :dismissible="false">
    <div class="p-4 pb-8">
      <p class="text-p-base text-ink-gray-7">
        Outside click, Escape and swipe-down are all off. The sheet closes only
        through these buttons.
      </p>
      <div class="mt-4 flex gap-2">
        <Button class="flex-1" label="Keep editing" @click="open = false" />
        <Button
          class="flex-1"
          variant="solid"
          theme="red"
          label="Discard"
          @click="open = false"
        />
      </div>
    </div>
  </BottomSheet>
</template>

Gestures

Dragging works from anywhere on the sheet, not just the handle. A gesture that starts inside a scrolled list scrolls that list instead of moving the sheet; it becomes a drag once the list is back at its top. A drag closes the sheet if it passes a distance threshold or ends in a fast flick, and springs back otherwise.

The gesture logic lives in the useSheetDrag composable, exported from the package root if you need the same behaviour on a surface of your own.

Events

update:open fires as soon as the sheet starts closing. after-leave fires once the close animation has finished — use it to reset state that would flicker if cleared mid-animation.

Notes

  • Use Dialog on desktop and BottomSheet on mobile. They are separate components rather than one responsive component, because the two have different dismiss affordances and different content rhythms.
  • The sheet takes no actions prop. Put buttons in the default slot, where they can sit inside the scroll region or below it as the layout needs.

API Reference

Show types
typescript
export interface BottomSheetProps {
  /** Controls whether the sheet is open (`v-model:open`). */
  open?: boolean

  /** Optional centered title rendered in the drag-handle area. */
  title?: string

  /** Allow outside-click, Escape, and swipe-down to close. Default `true`. */
  dismissible?: boolean
}

export interface BottomSheetEmits {
  /** Fired when the open state changes via `v-model:open`. */
  'update:open': [value: boolean]

  /** Fired after the close animation finishes. */
  'after-leave': []
}

export interface BottomSheetSlots {
  /** Sheet body, rendered in a scrollable region below the handle. */
  default?: () => any
}
open
boolean

Controls whether the sheet is open (`v-model:open`).

title
string

Optional centered title rendered in the drag-handle area.

dismissible
= true
boolean

Allow outside-click, Escape, and swipe-down to close. Default `true`.

default
{}
update:open
[value: boolean]

Fired when the open state changes via `v-model:open`.

after-leave
[]

Fired after the close animation finishes.