GrDrawer
A sliding panel for secondary content and quick actions.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- the panel comes from an edge — filters, the details of a record, settings: the content is related to the current screen rather than replacing it;
- the content is tall — a vertical panel holds a long form better than a window in the centre;
- the page has to stay usable —
:modal="false"leaves the background available and does not block scrolling; - mobile navigation — a panel from the left edge instead of a menu in the header.
When to take something else
| Need | Take |
|---|---|
| A window in the centre with a header and a footer of its own | GrDialog |
| A layout of your own for the modal layer | GrModal |
| The content is tied to a button | GrPopover |
| Permanent side navigation rather than a sliding one | GrSidebar |
| Ask a yes/no question | GrConfirmDialog |
The sides
side is right (the default), left, top, bottom. The axis decides everything
else: a side panel is stretched vertically and takes its width from the scale, the
top and the bottom ones are stretched horizontally and take their height. The panel
slides in from its own side.
An arbitrary size is set along the same axis: width for left/right, height for
top/bottom. A prop of the wrong axis does not apply and complains in a dev build: a
silently ignored width on a bottom panel looks like a bug in the component rather than
an error in the call.
<!-- a bottom sheet: the height comes from the scale -->
<GrDrawer v-model="open" side="bottom" size="sm" title="Filters" />
<!-- a height of your own -->
<GrDrawer v-model="open" side="bottom" :height="320" />The modal and the non-modal mode
By default the panel is modal: a backdrop, a lock on page scrolling, inert for
everything else and a focus trap — Tab walks in a ring inside.
:modal="false" removes all of that. The panel stays on top of the page, but the page
keeps working: it scrolls, it is clickable and it accepts Tab, because the root of the
layer passes clicks through itself (pointer-events: none), and only the panel itself is
clickable. This is the mode for filters above a table and similar panels that are worked
with in parallel with the main screen.
What remains in the non-modal mode: a place in the shared layer stack (Esc closes the
top layer), the return of focus to the trigger and role="dialog" — but without
aria-modal now. There is no backdrop, so closeOnBackdrop affects nothing in this
mode.
<GrDrawer v-model="open" :modal="false" side="right" title="Filters">
…
</GrDrawer>The layer and the backdrop
A drawer lives on --gr-z-modal — the same layer as GrModal. That used to be the
literal z-50, below the whole scale: the panel of a dropdown or a select (1000) was
drawn on top of the opened drawer, and its backdrop did not cover it.
Inside the layer the height is refined by the stack: a drawer opened on top of a window
gets calc(var(--gr-z-modal) + depth) and is drawn above it regardless of the order in
which the components were mounted (../z-index.md).
The backdrop is the --gr-overlay-bg token, shared with GrModal: in the dark theme it
is denser, otherwise the panel does not separate from the background. There used to be a
bg-black/40 here — the only overlay background that did not follow the theme.
The header, the sections and API parity with GrDialog
The props coincide with GrDialog on purpose: showHeader, showCloseButton,
headerConfig/bodyConfig/footerConfig (paddingX, paddingY, bordered). The
consumer must not have to relearn when moving between two overlays of the same library.
The header renders only when there is something to show — a heading or a close button. An
empty title counts as absent: the word “Drawer” used to appear in its place.
The #header slot replaces the header as a whole — together with the close button, which
in that case is drawn by the consumer. The slot receives title and close:
<GrDrawer v-model="open" title="Filters">
<template #header="{ title, close }">
<GrInput v-model="query" :placeholder="title" />
<GrButton variant="ghost" @click="close">Done</GrButton>
</template>
</GrDrawer>
The layer always has a name and always a meaningful one: the heading is linked through
aria-labelledby, and when there is no header (showHeader: false) or it has been
replaced by a slot, the same heading is rendered hidden (sr-only). The generic name
from i18n remains a safety net only for a panel with no heading at all: a layer without a
name is a violation of aria-dialog-name.
The body of the panel scrolls and therefore enters the tab order (tabindex="0"):
otherwise a long text without a single focusable element cannot be scrolled from the
keyboard (axe: scrollable-region-focusable).
<GrDrawer
v-model="open"
title="Filters"
:header-config="{ paddingX: 'px-8' }"
:body-config="{ paddingY: 'py-4' }"
>
…
<template #footer>…</template>
</GrDrawer>The size
size is the overlay scale (sm…full), not the control one; a global
<GrConfigProvider size="…"> does not touch it, and the only channel is a pointed
componentDefaults (size, side). The value is read along the axis of the panel: the
width for the side ones, the height for the top and the bottom. width/height set an
arbitrary size and cancel the size class.
<GrConfigProvider :component-defaults="{ GrDrawer: { size: 'lg', side: 'left' } }">
<GrDrawer v-model="open" :width="640" />
</GrConfigProvider>Closing and the lifecycle
closeOnBackdrop and closeOnEsc govern the “soft” ways of closing; persistent
forbids both for the duration of an operation that must not be abandoned halfway. The
close button stays in the process: a panel with no way out is a trap.
@opened/@closed fire when the animation ends — that is where the loading of extra
content and the resetting of state are hung. initialFocus sets the element that gets the
focus on opening (by default the panel itself).
Imperatively: close() (bypassing persistent) and focus() — to return the focus to
the panel if an operation has taken it outside.
Playground 10
Loading…
<GrDrawer />Install
npm i @feugene/granularityImport
import { GrDrawer } from '@feugene/granularity/components/GrDrawer'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
title | string | undefined | undefined | The heading; if it is passed, it is shown in the header. It can be overridden with the `#title` slot. |
size | "sm" | "md" | "lg" | "xl" | "full" | undefined | undefined | The size of the panel along its axis: the width for the side ones, the height for the top and the bottom. |
closeOnBackdrop | boolean | undefined | true | Close on a click on the backdrop. In the non-modal mode there is no backdrop. |
closeOnEsc | boolean | undefined | true | Close on Esc. |
showHeader | boolean | undefined | true | Whether to render the header (the heading plus the close button). |
showCloseButton | boolean | undefined | true | Whether to render the close button in the header. |
headerConfig | GrDrawerSectionConfig | undefined | undefined | The padding and the border of the sections — as in `GrDialog`. |
footerConfig | GrDrawerSectionConfig | undefined | undefined | — |
bodyConfig | GrDrawerSectionConfig | undefined | undefined | — |
closeLabel | string | undefined | undefined | An i18n-friendly aria-label for the close button. |
persistent | boolean | undefined | false | A ban on the "soft" ways of closing (the backdrop, Esc) — for the duration of an operation that must not be abandoned halfway. The close button remains in the process: a panel with no way out is a trap. |
initialFocus | HTMLElement | null | undefined | null | The element that gets the focus on opening. By default — the panel itself. |
modal | boolean | undefined | true | A modal panel: a backdrop, a scroll lock, `inert` for the rest of the page and a focus trap. `false` — the panel lives beside the page: it is worked with without closing it, and Tab goes outside. |
side | GrDrawerSide | undefined | undefined | The side the panel slides in from. |
width | string | number | undefined | undefined | An arbitrary width of a side panel. A number is treated as pixels; it is stronger than `size`. |
height | string | number | undefined | undefined | An arbitrary height of a top or bottom panel. A number is treated as pixels. |
modelValuerequired | boolean | — | Control of the opening through v-model. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | — |
title | any | — |
header | { title?: string | undefined; close: () => void; } | A header of your own as a whole: the heading, the close button and everything needed beside them. |
footer | any | — |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: boolean] | — |
opened | [] | — |
closed | [] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
close | () => void | Close the panel (the equivalent of `v-model = false`), bypassing `persistent`. |
focus | () => void | undefined | Return the focus to the panel — after an operation that took it outside, for instance. |
Examples 7
Filter panel drawer
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrDrawer } from '@feugene/granularity'
const open = ref(false)
</script>
<template>
<div class="grid gap-3">
<GrButton class="justify-self-start" @click="open = true">
Open filters drawer
</GrButton>
<GrDrawer v-model="open" title="Report filters" size="sm">
<div class="grid gap-4 text-sm text-[var(--gr-muted-fg)]">
<label class="grid gap-2">
<span class="text-[var(--gr-fg)]">Owner</span>
<input class="rounded-lg border border-[var(--gr-brd)] bg-transparent px-3 py-2" value="Operations">
</label>
<label class="grid gap-2">
<span class="text-[var(--gr-fg)]">Date range</span>
<input class="rounded-lg border border-[var(--gr-brd)] bg-transparent px-3 py-2" value="Last 30 days">
</label>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<GrButton variant="outline" @click="open = false">
Reset
</GrButton>
<GrButton @click="open = false">
Apply filters
</GrButton>
</div>
</template>
</GrDrawer>
</div>
</template>Bottom sheet
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrDrawer, GrSegmented } from '@feugene/granularity'
const open = ref(false)
const sort = ref('recent')
const options = [
{ value: 'recent', label: 'Newest first' },
{ value: 'amount', label: 'Largest amount' },
{ value: 'status', label: 'By status' },
]
</script>
<template>
<div class="grid gap-3">
<GrButton class="justify-self-start" @click="open = true">
Open bottom sheet
</GrButton>
<!-- Сторона решает ось: `size` у нижней панели — это высота, а не ширина. -->
<GrDrawer v-model="open" side="bottom" size="sm" title="Sort orders">
<GrSegmented v-model="sort" :options="options" class="w-full" />
<template #footer>
<div class="flex justify-end">
<GrButton @click="open = false">
Apply
</GrButton>
</div>
</template>
</GrDrawer>
</div>
</template>Non-modal filters
| Invoice | Client | Status |
|---|---|---|
| INV-1042 | Northwind | Overdue |
| INV-1043 | Contoso | Paid |
| INV-1044 | Fabrikam | Overdue |
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrCheckbox, GrDrawer, GrTable } from '@feugene/granularity'
const open = ref(false)
const onlyOverdue = ref(false)
const clicks = ref(0)
const rows = [
{ id: 'INV-1042', client: 'Northwind', status: 'Overdue' },
{ id: 'INV-1043', client: 'Contoso', status: 'Paid' },
{ id: 'INV-1044', client: 'Fabrikam', status: 'Overdue' },
]
const visibleRows = () => (onlyOverdue.value ? rows.filter(row => row.status === 'Overdue') : rows)
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap items-center gap-3">
<GrButton class="justify-self-start" @click="open = true">
Open filters
</GrButton>
<!-- Страница под немодальной панелью остаётся живой: счётчик растёт. -->
<GrButton variant="outline" @click="clicks++">
Table still responds: {{ clicks }}
</GrButton>
</div>
<GrTable>
<template #header>
<tr>
<th class="px-4 py-2 text-left">Invoice</th>
<th class="px-4 py-2 text-left">Client</th>
<th class="px-4 py-2 text-left">Status</th>
</tr>
</template>
<tr v-for="row in visibleRows()" :key="row.id">
<td class="px-4 py-2">{{ row.id }}</td>
<td class="px-4 py-2">{{ row.client }}</td>
<td class="px-4 py-2">{{ row.status }}</td>
</tr>
</GrTable>
<!-- `modal: false` — ни подложки, ни блокировки скролла, ни ловушки фокуса:
с панелью работают, не закрывая её. Esc закрывает по-прежнему. -->
<GrDrawer v-model="open" :modal="false" size="sm" title="Invoice filters">
<GrCheckbox v-model="onlyOverdue">
Only overdue
</GrCheckbox>
<template #footer>
<GrButton variant="outline" class="w-full" @click="open = false">
Done
</GrButton>
</template>
</GrDrawer>
</div>
</template>Custom header
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrButton, GrDrawer, GrInput } from '@feugene/granularity'
const open = ref(false)
const query = ref('')
const members = ['Ada Lovelace', 'Alan Turing', 'Grace Hopper', 'Edsger Dijkstra']
const found = computed(() =>
members.filter(name => name.toLowerCase().includes(query.value.trim().toLowerCase())),
)
</script>
<template>
<div class="grid gap-3">
<GrButton class="justify-self-start" @click="open = true">
Open member picker
</GrButton>
<GrDrawer v-model="open" title="Team members" size="sm">
<!-- Своя шапка заменяет и заголовок, и крестик. Имя слоя при этом
остаётся: заголовок уходит в скрытый элемент. -->
<template #header="{ title, close }">
<div class="flex items-center gap-2">
<GrInput v-model="query" :placeholder="title" class="flex-1" />
<GrButton variant="ghost" size="sm" @click="close">
Done
</GrButton>
</div>
</template>
<ul class="grid gap-1 text-sm">
<li v-for="name in found" :key="name" class="rounded-md px-2 py-1.5 hover:bg-[var(--gr-muted)]">
{{ name }}
</li>
<li v-if="found.length === 0" class="px-2 py-1.5 text-[var(--gr-muted-fg)]">
Nobody matches “{{ query }}”
</li>
</ul>
</GrDrawer>
</div>
</template>Left navigation rail
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrDrawer } from '@feugene/granularity'
const open = ref(false)
const activeItem = ref('Overview')
const items = ['Overview', 'Approvals', 'Members', 'Security']
</script>
<template>
<div class="grid gap-3">
<GrButton variant="outline" class="justify-self-start" @click="open = true">
Open left rail
</GrButton>
<div class="text-xs text-[var(--gr-muted-fg)]">
Active section: <span class="font-medium text-[var(--gr-fg)]">{{ activeItem }}</span>
</div>
<GrDrawer v-model="open" title="Workspace sections" side="left" size="sm">
<div class="grid gap-2">
<button
v-for="item in items"
:key="item"
type="button"
class="rounded-lg px-3 py-2 text-left text-sm transition"
:class="item === activeItem ? 'bg-[var(--gr-accent)] text-[var(--gr-accent-fg)]' : 'border border-[var(--gr-brd)] text-[var(--gr-muted-fg)]'"
@click="activeItem = item"
>
{{ item }}
</button>
</div>
</GrDrawer>
</div>
</template>Size switch with guarded backdrop
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrDrawer } from '@feugene/granularity'
const open = ref(false)
const size = ref<'md' | 'lg'>('md')
function openDrawer(nextSize: 'md' | 'lg') {
size.value = nextSize
open.value = true
}
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap gap-3">
<GrButton variant="outline" @click="openDrawer('md')">
Open review drawer
</GrButton>
<GrButton @click="openDrawer('lg')">
Open wide drawer
</GrButton>
</div>
<GrDrawer v-model="open" :title="`Escalation summary (${size})`" :size="size" :close-on-backdrop="false">
<div class="grid gap-3 text-sm text-[var(--gr-muted-fg)]">
<p>Размер drawer удобно переключать под compact review или широкие inspector-сценарии.</p>
<p>Backdrop закрытие отключено, чтобы случайный клик не сбрасывал прогресс.</p>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<GrButton variant="outline" @click="open = false">
Continue later
</GrButton>
<GrButton @click="open = false">
Resolve now
</GrButton>
</div>
</template>
</GrDrawer>
</div>
</template>Persistent form
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrDrawer, GrFormField, GrInput, GrTextarea } from '@feugene/granularity'
const open = ref(false)
const saving = ref(false)
const status = ref('—')
const name = ref('Nightly backup')
const note = ref('')
const nameInput = ref<HTMLElement | null>(null)
async function save(): Promise<void> {
saving.value = true
status.value = 'saving — drawer is locked'
await new Promise(resolve => setTimeout(resolve, 1200))
saving.value = false
open.value = false
status.value = `saved “${name.value}”`
}
</script>
<template>
<div class="grid gap-3">
<GrButton class="justify-self-start" @click="open = true">
Edit job
</GrButton>
<GrDrawer
v-model="open"
title="Edit job"
:persistent="saving"
:initial-focus="nameInput"
:body-config="{ paddingY: 'py-4' }"
@opened="status = 'opened'"
@closed="status = status.startsWith('saved') ? status : 'closed'"
>
<div class="grid gap-4">
<GrFormField label="Job name">
<GrInput ref="nameInput" v-model="name" size="sm" />
</GrFormField>
<GrFormField label="Note" hint="Виден только команде дежурных">
<GrTextarea v-model="note" :rows="4" />
</GrFormField>
<p class="text-sm text-[var(--gr-muted-fg)]">
Пока идёт сохранение, панель `persistent`: ни Esc, ни клик по подложке её не закроют —
кнопка закрытия остаётся, чтобы выход был хотя бы один.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<GrButton variant="outline" :disabled="saving" @click="open = false">
Cancel
</GrButton>
<GrButton :loading="saving" @click="save">
Save
</GrButton>
</div>
</template>
</GrDrawer>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
Lifecycle: <span class="font-semibold text-[var(--gr-fg)]">{{ status }}</span>
</div>
</div>
</template>Accessibility
- APG pattern
| GrConfirmDialog | Фокус при открытии — на «Отмена» (focusAction: confirm \| cancel \| none), поэтому Enter сразу после открытия отменяет, а не подтверждает. persistent на время асинхронного подтверждения снимает Esc и клик по бэкдропу, крестик и «Отмена» остаются