Drawer
A panel that slides in from the edge of the screen — for filters, mobile menus and details.
'use client';
import { Button, Checkbox, Drawer, Field, Input, Switch } from '@akhatmirzo/ui';
import { SlidersHorizontal } from 'lucide-react';
import { useState } from 'react';
const categories = ['Elektronika', 'Kiyim-kechak', "Uy-ro'zg'or", 'Kitoblar'];
export default function DrawerFilters() {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>(['Elektronika']);
const toggle = (name: string, on: boolean) =>
setSelected((prev) => (on ? [...prev, name] : prev.filter((x) => x !== name)));
return (
<Drawer open={open} onOpenChange={setOpen}>
<Drawer.Trigger asChild>
<Button variant="outline" leftIcon={<SlidersHorizontal />}>
Filtrlar
</Button>
</Drawer.Trigger>
<Drawer.Content title="Filtrlar" description="Natijalarni toraytiring.">
<div className="flex flex-col gap-5">
<div className="grid grid-cols-2 gap-3">
<Field label="Narxdan">
<Input inputMode="numeric" placeholder="0" suffix="so'm" />
</Field>
<Field label="Narxgacha">
<Input inputMode="numeric" placeholder="1 000 000" suffix="so'm" />
</Field>
</div>
<div className="flex flex-col gap-3">
{categories.map((c) => (
<Checkbox
key={c}
label={c}
checked={selected.includes(c)}
onCheckedChange={(on) => toggle(c, on)}
/>
))}
</div>
<Switch label="Faqat mavjudlari" defaultChecked />
</div>
<Drawer.Footer>
<Button onClick={() => setSelected([])}>Tozalash</Button>
<Button variant="primary" onClick={() => setOpen(false)}>
Qo'llash
</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
);
}
Import
import { Drawer } from '@akhatmirzo/ui';When to use
- Filter panels — many filter fields fit at the side without covering the whole page.
- Detail views — open an order or a customer when a table row is clicked.
- Mobile menus — navigation that slides in from the left with
side="left". - Short forms and confirmations belong in a
Dialog, which keeps attention in the center. - A Drawer is modal too: the page is blocked while it's open. If users need to keep working with the page, build a regular side panel into your layout instead.
Anatomy
The API mirrors Dialog — same part names, same behavior.
| Part | What it does |
|---|---|
Drawer | State: open, defaultOpen, onOpenChange |
Drawer.Trigger | Opens the drawer (use with asChild) |
Drawer.Content | The panel: backdrop, title, description, ✕ and focus trap |
Drawer.Footer | The button row — sticks to the bottom of the panel |
Drawer.Close | Closes the drawer when clicked (use with asChild) |
<Drawer>
<Drawer.Trigger asChild>
<Button>Filters</Button>
</Drawer.Trigger>
<Drawer.Content title="Filters" description="Narrow down the results.">
{/* fields */}
<Drawer.Footer>
<Drawer.Close asChild>
<Button>Cancel</Button>
</Drawer.Close>
<Button variant="primary">Apply</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>Sides
side picks the edge: right (default), left, bottom or top. A bottom drawer gets a
decorative handle bar at the top and rounded corners.
'use client';
import { Button, Drawer, type DrawerSide } from '@akhatmirzo/ui';
const sides: { side: DrawerSide; label: string }[] = [
{ side: 'right', label: "O'ngdan" },
{ side: 'left', label: 'Chapdan' },
{ side: 'bottom', label: 'Pastdan' },
{ side: 'top', label: 'Tepadan' },
];
export default function DrawerSides() {
return (
<div className="flex flex-wrap items-center gap-3">
{sides.map(({ side, label }) => (
<Drawer key={side}>
<Drawer.Trigger asChild>
<Button variant="outline">{label}</Button>
</Drawer.Trigger>
<Drawer.Content side={side} title={label} description={`side="${side}"`}>
<p className="m-0 text-sm text-text-muted">
Panel ekran chetidan siljib chiqadi. Pastdan chiqqanda tepada tutqich chizig'i bor.
</p>
<Drawer.Footer>
<Drawer.Close asChild>
<Button variant="primary">Yopish</Button>
</Drawer.Close>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
))}
</div>
);
}
Sizes
size is sm (320px), md (420px, default) or lg (560px). On the left and right it sets the
width; at the top and bottom, the height. On narrow screens the panel never grows past the
viewport.
'use client';
import { Button, Drawer, type DrawerSize } from '@akhatmirzo/ui';
const sizes: { size: DrawerSize; px: string }[] = [
{ size: 'sm', px: '320px' },
{ size: 'md', px: '420px' },
{ size: 'lg', px: '560px' },
];
export default function DrawerSizes() {
return (
<div className="flex flex-wrap items-center gap-3">
{sizes.map(({ size, px }) => (
<Drawer key={size}>
<Drawer.Trigger asChild>
<Button variant="outline">
{size} — {px}
</Button>
</Drawer.Trigger>
<Drawer.Content size={size} title={`O'lcham: ${size}`} description={`Kenglik ${px}`}>
<p className="m-0 text-sm text-text-muted">
Chap va o'ngda o'lcham kenglikni, past va tepada balandlikni belgilaydi.
</p>
</Drawer.Content>
</Drawer>
))}
</div>
);
}
Mobile menu
For navigation, use side="left" with size="sm". If the title shouldn't be visible, add
hideTitle — screen readers still read it. Wrap each link in Drawer.Close asChild so the menu
closes when an item is clicked.
'use client';
import { Button, Drawer } from '@akhatmirzo/ui';
import { Menu } from 'lucide-react';
import { useState } from 'react';
const items = ['Bosh sahifa', 'Buyurtmalar', 'Mahsulotlar', 'Mijozlar', 'Hisobotlar'];
export default function DrawerMobileMenu() {
const [active, setActive] = useState('Buyurtmalar');
return (
<Drawer>
<Drawer.Trigger asChild>
<Button variant="ghost" leftIcon={<Menu />}>
Menyu
</Button>
</Drawer.Trigger>
<Drawer.Content
side="left"
size="sm"
title="Menyu"
hideTitle
labels={{ close: 'Menyuni yopish' }}
>
<nav aria-label="Asosiy menyu" className="-mx-2 flex flex-col gap-1">
{items.map((item) => (
<Drawer.Close asChild key={item}>
<button
type="button"
aria-current={item === active ? 'page' : undefined}
onClick={() => setActive(item)}
className="focus-ring flex h-10 cursor-pointer items-center rounded-lg border-0 bg-transparent px-3 text-left text-sm font-medium text-text hover:bg-surface-muted aria-[current=page]:bg-primary-soft aria-[current=page]:text-primary-soft-foreground"
>
{item}
</button>
</Drawer.Close>
))}
</nav>
</Drawer.Content>
</Drawer>
);
}
Details
size="lg" gives a roomy panel for order or customer details. Drawer.Footer stays at the
bottom even when the content is short.
'use client';
import { Button, Drawer } from '@akhatmirzo/ui';
const rows = [
['Mijoz', 'Sarvar Abduxoliqov'],
['Telefon', '+998 90 123 45 67'],
['Manzil', 'Toshkent, Chilonzor tumani, 9-mavze'],
["To'lov", 'Naqd, yetkazib berilganda'],
['Jami', "1 250 000 so'm"],
];
export default function DrawerDetails() {
return (
<Drawer>
<Drawer.Trigger asChild>
<Button>Buyurtma #1042</Button>
</Drawer.Trigger>
<Drawer.Content size="lg" title="Buyurtma #1042" description="21-oktabr, 14:32">
<dl className="m-0 grid grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-sm">
{rows.map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-text-muted">{label}</dt>
<dd className="m-0 font-medium text-text">{value}</dd>
</div>
))}
</dl>
<Drawer.Footer>
<Drawer.Close asChild>
<Button>Yopish</Button>
</Drawer.Close>
<Button variant="primary">Yetkazildi deb belgilash</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
);
}
Select and Combobox inside
Select, Combobox, DropdownMenu and
Popover inside Drawer.Content open above the drawer — no extra setup.
Props
Drawer
Accepts the props of the Radix Dialog Root: open, defaultOpen, onOpenChange, modal.
Drawer.Content
Other <div> attributes, ref and Radix events (onEscapeKeyDown, onOpenAutoFocus ...) are
passed to the panel.
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | ReactNode | — | The title — also used by screen readers |
description | ReactNode | — | Text under the title |
side | 'right' | 'left' | 'bottom' | 'top' | 'right' | Which edge it slides in from |
size | 'sm' | 'md' | 'lg' | 'md' | 320 / 420 / 560px — width or height |
hideTitle | boolean | false | Hides the title visually (still read aloud) |
closeButton | boolean | true | ✕ button in the top-right corner |
dismissible | boolean | true | false — outside clicks don't close it (Esc still does) |
overlayClassName | string | — | Class for the backdrop |
container | HTMLElement | null | — | Portal container (defaults to document.body) |
labels | { close?: string } | { close: 'Yopish' } | Accessible name of the ✕ button |
Drawer.Trigger, Drawer.Close, Drawer.Footer
Drawer.Trigger and Drawer.Close take your own element via asChild. Drawer.Footer is a
plain <div>: buttons align to the right and the row sticks to the bottom of the panel
(mt-auto).
Data attributes
| Attribute | On | Value |
|---|---|---|
data-state | panel | open · closed |
data-side | panel | right · left · bottom · top |
data-size | panel | sm · md · lg |
To choose the initial focus yourself, put data-autofocus on the element.
Keyboard
| Key | Action |
|---|---|
Tab | Next element inside the panel (focus never leaves it) |
Shift + Tab | Previous element |
Esc | Closes the drawer; focus returns to where it came from |
Accessibility
- Role and name. The panel is
role="dialog", witharia-labelledbypointing to the title andaria-describedbyto the description (left out when there is none). - Focus trap and scrolling. While open, focus cycles inside the panel; the page can't scroll and is hidden from screen readers.
- Initial focus:
[data-autofocus]→ the first field → the first button (other than ✕) → the panel itself. - Returning focus. On close, focus goes back to the trigger — or, without one, to the element that was focused before opening.
- The handle bar (
side="bottom") is decorative andaria-hidden. - Reduced motion. With
prefers-reduced-motion, the slide becomes a fade.