Component 17 of 60
Drawer
A panel that slides from an edge of the window.
Starting the preview. GPUI compiles to WebAssembly, so the first load takes a few seconds. This browser has neither WebGPU nor WebGL 2, so the live preview cannot run. The code on this page still works. The preview did not start.
fn drawer_preview(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let handle = window
.use_keyed_state("drawer.handle", cx, |_, _| DialogHandle::new(false))
.read(cx)
.clone();
let side = self.drawer_direction;
let theme = UiTheme::read(cx).clone();
let header = drawer_header(side, cx)
.child(drawer_title("drawer.title", cx).child("Move goal"))
.child(
drawer_description("drawer.description", cx).child("Set your daily activity goal."),
);
let body = drawer_body("drawer.body", cx).pt_0().child(
div()
.mx_auto()
.w_full()
.max_w(theme.space(80.))
.flex()
.flex_col()
.gap(theme.space(5.))
.child(
div()
.flex()
.items_center()
.justify_between()
.gap(theme.space(4.))
.child(
Button::new("goal-minus")
.variant(ButtonVariant::Outline)
.label("−")
.aria_label("Decrease daily goal")
.rounded_full()
.on_click(cx.listener(|this, _, _, cx| {
this.goal = (this.goal - 10).max(10);
cx.notify();
})),
)
.child(
div()
.flex()
.flex_col()
.items_center()
.child(
div()
.text_size(theme.text(48.))
.font_weight(gpui_kit::FontWeight::SEMIBOLD)
.child(self.goal.to_string()),
)
.child(
div()
.text_color(theme.colors.muted_foreground)
.text_size(theme.text(11.))
.child("CALORIES / DAY"),
),
)
.child(
Button::new("goal-plus")
.variant(ButtonVariant::Outline)
.label("+")
.aria_label("Increase daily goal")
.rounded_full()
.on_click(cx.listener(|this, _, _, cx| {
this.goal += 10;
cx.notify();
})),
),
)
.child(
div()
.flex()
.items_end()
.gap(theme.space(1.5))
.h(theme.space(16.))
.children(
[40., 65., 45., 85., 55., 95., 70., 60., 90., 75., 50., 80.]
.into_iter()
.map(|height| {
div()
.flex_1()
.h(theme.space(16.) * (height / 100.))
.rounded_t(theme.radius.sm)
.bg(theme.colors.foreground)
}),
),
),
);
let footer = drawer_footer(cx)
.mx_auto()
.w_full()
.max_w(theme.space(88.))
.child(
drawer_close("goal-save", &handle)
.variant(ButtonVariant::Default)
.label("Save goal"),
)
.child(drawer_close("goal-cancel", &handle).label("Cancel"));
Drawer::new("drawer", &handle)
.direction(side)
.show_swipe_handle(true)
.flex()
.flex_col()
.items_center()
.gap(theme.space(4.))
.child(drawer_trigger("drawer.trigger", &handle).label("Open drawer"))
.child(
div().flex().gap(theme.space(2.)).children(
[
("top", DrawerSide::Top),
("right", DrawerSide::Right),
("bottom", DrawerSide::Bottom),
("left", DrawerSide::Left),
]
.into_iter()
.map(|(label, direction)| {
Button::new(label)
.variant(if side == direction {
ButtonVariant::Secondary
} else {
ButtonVariant::Ghost
})
.label(label)
.on_click(cx.listener(move |this, _, _, cx| {
this.drawer_direction = direction;
cx.notify();
}))
}),
),
)
.content(
DrawerContent::new("drawer.content", "Move goal")
.child(header)
.child(body)
.child(footer),
)
}
Installation
After the one-time setup, run the command from your app root. The CLI
writes the modules and keeps src/ui/mod.rs in step.
gpuicn add drawer
If you prefer to copy the files by hand, declare them yourself:
// src/ui/mod.rs
pub mod theme;
pub mod button;
pub mod dialog;
pub mod drawer;
// src/main.rs
mod ui;
Usage
Put this in a view module and return example(handle, cx) from its render method.
use crate::ui::drawer::*;
use gpui_kit::{App, IntoElement, ParentElement};
fn example(handle: &DrawerHandle, cx: &App) -> impl IntoElement {
Drawer::new("profile", handle)
.direction(DrawerSide::Bottom)
.show_swipe_handle(true)
.child(drawer_trigger("profile.open", handle).label("Edit profile"))
.content(
DrawerContent::new("profile.content", "Edit profile")
.child(
drawer_header(DrawerSide::Bottom, cx)
.child(drawer_title("profile.title", cx).child("Edit profile"))
.child(
drawer_description("profile.description", cx)
.child("Make changes to your profile."),
),
)
.child(drawer_body("profile.body", cx).child("Your profile fields go here."))
.child(
drawer_footer(cx)
.child(drawer_close("profile.cancel", handle).label("Done")),
),
)
}
API reference
pub fn new(id: impl Into<ElementId>, handle: &DrawerHandle) -> Self
Creates a bottom drawer backed by a caller-owned open/close handle.
pub fn direction(mut self, side: DrawerSide) -> Self
Sets the opening edge and swipe direction.
pub fn show_swipe_handle(mut self, show: bool) -> Self
Adds an axis-aware grip at the exposed edge of the panel.
pub fn dismissible(mut self, dismissible: bool) -> Self
Allows Escape, backdrop presses and swipes to dismiss. Close buttons remain explicit.
pub fn content(mut self, content: DrawerContent) -> Self
Supplies the modal content; header and footer remain outside its scrollable body.
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self
Creates an accessibly named panel with a stable caller-owned ID.
pub fn drawer_trigger(id: impl Into<ElementId>, handle: &DrawerHandle) -> Button
A keyboard-accessible trigger opening the caller's drawer.
pub fn drawer_close(id: impl Into<ElementId>, handle: &DrawerHandle) -> Button
An explicit close action, with an outline appearance by default.
pub fn drawer_header(side: DrawerSide, cx: &App) -> Div
Inset heading region; vertical drawers center their headings, side drawers align left.
pub fn drawer_body(id: impl Into<ElementId>, cx: &App) -> Stateful<Div>
Scrollable body that shrinks before the header and footer on short viewports.
pub fn drawer_footer(cx: &App) -> Div
Stacked actions pinned after the body, with consistent insets.
pub fn drawer_title(id: impl Into<ElementId>, cx: &App) -> Stateful<Div>
A semantic drawer heading using Nova typography.
pub fn drawer_description(id: impl Into<ElementId>, cx: &App) -> Stateful<Div>
Muted supporting text for the drawer heading.
Installed source, 446 lines
//! Nova drawers with composable content, retained presence and directional swipes.
use super::{
button::{Button, ButtonVariant},
dialog::{dialog_backdrop, dialog_description, dialog_title, modal_focus},
theme::{UiEasing, UiTheme, apply_style, presence_with, primitives::surface, transition_with},
};
pub use gpui_kit::base::DialogHandle as DrawerHandle;
use gpui_kit::base::{ElementExt as _, Sheet};
use gpui_kit::{
AnyElement, App, Div, ElementId, InteractiveElement as _, IntoElement, MouseButton,
ParentElement, Pixels, Point, RenderOnce, Role, SharedString, Size, Stateful,
StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
prelude::FluentBuilder as _, px,
};
use std::time::Duration;
/// The viewport edge from which a drawer opens and toward which it dismisses.
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub enum DrawerSide {
/// Attach to the top edge.
Top,
/// Attach to the right edge.
Right,
/// Attach to the bottom edge.
#[default]
Bottom,
/// Attach to the left edge.
Left,
}
impl DrawerSide {
fn vertical(self) -> bool {
matches!(self, Self::Top | Self::Bottom)
}
fn distance(self, start: Point<Pixels>, end: Point<Pixels>) -> Pixels {
match self {
Self::Top => start.y - end.y,
Self::Bottom => end.y - start.y,
Self::Left => start.x - end.x,
Self::Right => end.x - start.x,
}
}
}
/// A drawer root. Keep it mounted while closed, with its trigger and content.
#[derive(IntoElement)]
pub struct Drawer {
id: ElementId,
handle: DrawerHandle,
side: DrawerSide,
swipe_handle: bool,
dismissible: bool,
content: Option<DrawerContent>,
base: Div,
}
impl Drawer {
/// Creates a bottom drawer backed by a caller-owned open/close handle.
pub fn new(id: impl Into<ElementId>, handle: &DrawerHandle) -> Self {
Self {
id: id.into(),
handle: handle.clone(),
side: DrawerSide::Bottom,
swipe_handle: false,
dismissible: true,
content: None,
base: div(),
}
}
/// Sets the opening edge and swipe direction.
pub fn direction(mut self, side: DrawerSide) -> Self {
self.side = side;
self
}
/// Adds an axis-aware grip at the exposed edge of the panel.
pub fn show_swipe_handle(mut self, show: bool) -> Self {
self.swipe_handle = show;
self
}
/// Allows Escape, backdrop presses and swipes to dismiss. Close buttons remain explicit.
pub fn dismissible(mut self, dismissible: bool) -> Self {
self.dismissible = dismissible;
self
}
/// Supplies the modal content; header and footer remain outside its scrollable body.
pub fn content(mut self, content: DrawerContent) -> Self {
self.content = Some(content);
self
}
}
impl Styled for Drawer {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl ParentElement for Drawer {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.base.extend(elements);
}
}
/// The styled panel. Override its width or height with standard GPUI sizing methods.
pub struct DrawerContent {
id: ElementId,
label: SharedString,
children: Vec<AnyElement>,
style: StyleRefinement,
}
impl DrawerContent {
/// Creates an accessibly named panel with a stable caller-owned ID.
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
children: Vec::new(),
style: Default::default(),
}
}
}
impl Styled for DrawerContent {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl ParentElement for DrawerContent {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
/// A keyboard-accessible trigger opening the caller's drawer.
pub fn drawer_trigger(id: impl Into<ElementId>, handle: &DrawerHandle) -> Button {
let handle = handle.clone();
Button::new(id)
.variant(ButtonVariant::Outline)
.on_click(move |_, window, cx| handle.open(window, cx))
}
/// An explicit close action, with an outline appearance by default.
pub fn drawer_close(id: impl Into<ElementId>, handle: &DrawerHandle) -> Button {
let handle = handle.clone();
Button::new(id)
.variant(ButtonVariant::Outline)
.on_click(move |_, window, cx| handle.close(window, cx))
}
/// Inset heading region; vertical drawers center their headings, side drawers align left.
pub fn drawer_header(side: DrawerSide, cx: &App) -> Div {
let t = UiTheme::read(cx);
div()
.flex()
.flex_col()
.flex_shrink_0()
.gap(t.space(1.5))
.p(t.space(4.))
.when(side.vertical(), |d| d.text_center())
}
/// Scrollable body that shrinks before the header and footer on short viewports.
pub fn drawer_body(id: impl Into<ElementId>, cx: &App) -> Stateful<Div> {
div()
.id(id)
.min_h(px(0.))
.flex_1()
.overflow_y_scroll()
.p(UiTheme::read(cx).space(4.))
}
/// Stacked actions pinned after the body, with consistent insets.
pub fn drawer_footer(cx: &App) -> Div {
let t = UiTheme::read(cx);
div()
.flex()
.flex_col()
.flex_shrink_0()
.mt_auto()
.gap(t.space(2.))
.p(t.space(4.))
}
/// A semantic drawer heading using Nova typography.
pub fn drawer_title(id: impl Into<ElementId>, cx: &App) -> Stateful<Div> {
dialog_title(id, cx)
}
/// Muted supporting text for the drawer heading.
pub fn drawer_description(id: impl Into<ElementId>, cx: &App) -> Stateful<Div> {
dialog_description(id, cx)
}
#[derive(Default)]
struct Swipe {
start: Option<(Point<Pixels>, Pixels)>,
offset: Pixels,
size: Size<Pixels>,
}
impl RenderOnce for Drawer {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let base = self.base.id(self.id.clone());
let Some(content) = self.content else {
return base.into_any_element();
};
let open = self.handle.is_open();
let t = UiTheme::read(cx).clone();
let presence = presence_with(
(self.id.clone(), "presence"),
open,
t.motion.normal,
UiEasing::Emphasized,
window,
cx,
);
let visible = presence.should_render() && (open || presence.progress > 0.);
let focus = modal_focus::prepare(self.id.clone(), visible, window, cx);
let swipe = window.use_keyed_state((self.id.clone(), "swipe"), cx, |_, _| Swipe::default());
if !visible {
swipe.update(cx, |state, _| {
state.start = None;
state.offset = px(0.);
});
transition_with(
(self.id.clone(), "drag"),
0_f32,
Duration::ZERO,
UiEasing::Linear,
window,
cx,
);
return base.into_any_element();
}
let side = self.side;
let vertical = side.vertical();
let viewport = window.viewport_size();
let side_width = if viewport.width >= t.space(160.) {
t.space(96.)
} else {
viewport.width * 0.75
};
let state = swipe.read(cx);
let extent = if vertical {
state.size.height
} else {
state.size.width
};
let dragging = state.start.is_some();
let offset = transition_with(
(self.id.clone(), "drag"),
f32::from(state.offset),
if dragging {
Duration::ZERO
} else {
t.motion.normal
},
UiEasing::Emphasized,
window,
cx,
);
let extent = if extent > px(0.) {
extent
} else if vertical {
viewport.height
} else {
viewport.width
};
let shift = px(offset) + (extent - px(offset)).max(px(0.)) * (1. - presence.progress);
let opacity = presence.progress * (1. - offset / f32::from(extent)).clamp(0., 1.);
let scope = modal_focus::ModalFocus::new(self.id.clone());
let measured = swipe.clone();
let mut surface = scope
.trap(div(), true)
.id(content.id)
.debug_selector(|| "drawer-panel".into())
.track_focus(&focus)
.role(Role::Dialog)
.aria_label(content.label)
.absolute()
.occlude()
.flex()
.min_h(px(0.))
.overflow_hidden()
.map(|panel| surface::sheet(panel, &t))
.when(vertical, |d| d.flex_col())
.map(|d| match side {
DrawerSide::Bottom => d
.left_0()
.bottom(-shift)
.w_full()
.max_h((viewport.height - t.space(24.)).max(px(0.)))
.rounded_t(t.radius.xl)
.border_t_1(),
DrawerSide::Top => d
.left_0()
.top(-shift)
.w_full()
.max_h((viewport.height - t.space(24.)).max(px(0.)))
.rounded_b(t.radius.xl)
.border_b_1(),
DrawerSide::Left => d
.top_0()
.left(-shift)
.h(viewport.height)
.w(side_width)
.border_r_1(),
DrawerSide::Right => d
.top_0()
.right(-shift)
.h(viewport.height)
.w(side_width)
.border_l_1(),
})
.on_prepaint(move |bounds, window, cx| {
measured.update(cx, |state, _| {
if state.size != bounds.size {
state.size = bounds.size;
window.request_animation_frame();
}
});
})
.child(scope.boundary(false))
.child(scope.boundary(true));
let style = content.style;
let mut body = Some(
div()
.flex()
.flex_col()
.flex_1()
.min_h(px(0.))
.min_w(px(0.))
.children(content.children),
);
let grip_after = matches!(side, DrawerSide::Top | DrawerSide::Left);
if grip_after {
surface = surface.child(body.take().unwrap());
}
if self.swipe_handle {
let start = swipe.clone();
let enabled = self.dismissible && open;
surface = surface.child(
div()
.id((self.id.clone(), "grip"))
.debug_selector(|| "drawer-grip".into())
.flex()
.flex_shrink_0()
.items_center()
.justify_center()
.when(vertical, |d| d.w_full().h(t.space(7.)))
.when(!vertical, |d| d.h_full().w(t.space(7.)))
.when(enabled, |d| d.cursor_grab())
.child(
div()
.rounded_full()
.bg(t.colors.muted)
.when(vertical, |d| d.h(t.space(1.)).w(t.space(20.)))
.when(!vertical, |d| d.w(t.space(1.)).h(t.space(20.))),
)
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
if enabled {
start.update(cx, |state, _| {
state.start = Some((event.position, px(offset)));
state.offset = px(offset);
});
window.prevent_default();
cx.stop_propagation();
window.refresh();
}
}),
);
}
if !grip_after {
surface = surface.child(body.unwrap());
}
let moving = swipe.clone();
let releasing = swipe;
let release = self.handle.clone();
let close = self.handle;
let dismissible = self.dismissible;
let host = Sheet::new(cx)
.focus_handle(focus)
.overlay(dialog_backdrop(cx).opacity(opacity))
.overlay_closable(dismissible && open)
.surface(apply_style(surface, &style))
.request_close(move |window, cx| {
if dismissible && open {
close.close(window, cx);
}
});
base.child(
gpui_kit::deferred(
div()
.absolute()
.inset_0()
.child(
gpui_kit::canvas(
|_, _, _| (),
move |_, _, window, _| {
window.on_mouse_event(
move |event: &gpui_kit::MouseMoveEvent, phase, window, cx| {
if phase != gpui_kit::DispatchPhase::Capture
|| moving.read(cx).start.is_none()
{
return;
}
moving.update(cx, |state, _| {
if event.pressed_button == Some(MouseButton::Left) {
let (origin, initial) = state.start.unwrap();
state.offset = (initial
+ side.distance(origin, event.position))
.clamp(px(0.), extent);
} else {
state.start = None;
state.offset = px(0.);
}
});
window.refresh();
cx.stop_propagation();
},
);
window.on_mouse_event(
move |event: &gpui_kit::MouseUpEvent, phase, window, cx| {
if phase != gpui_kit::DispatchPhase::Capture
|| event.button != MouseButton::Left
|| releasing.read(cx).start.is_none()
{
return;
}
let dismissed = releasing.update(cx, |state, _| {
state.start = None;
let dismissed = state.offset >= extent * 0.25;
if !dismissed {
state.offset = px(0.);
}
dismissed
});
if dismissed {
release.close(window, cx);
}
window.refresh();
cx.stop_propagation();
},
);
},
)
.absolute()
.size_full(),
)
.child(host),
)
.with_priority(10),
)
.into_any_element()
}
}
Platform notes
Reference: shadcn/ui Drawer, checked 2026-09-10. Nova presentation, implemented with GPUI Kit 0.6.4.
Drawer is the root; drawer_trigger and drawer_close act on its handle.
Pass DrawerContent through .content(...). Compose its children with
drawer_header, drawer_title, drawer_description, drawer_body and
drawer_footer. These correspond to shadcn's root, trigger, close, content,
header, title, description and footer parts.
Keep the root mounted and retain its DrawerHandle. Use .direction(...) for
any edge and .show_swipe_handle(true) for an optional grip. Explicit close
buttons work even with .dismissible(false); that setting disables backdrop,
Escape and swipe dismissal. Application state and save actions stay caller-owned.
Vertical drawers size to their content, with a viewport-relative ceiling that
leaves 24 spacing units visible behind the panel. Side drawers use 75% of a narrow
viewport and 96 spacing units from the 160-unit breakpoint. Header and footer do
not shrink; the body scrolls when space runs out. Standard sizing methods on
DrawerContent override the defaults. Vertical headings center, while side
headings align left. Grips follow the axis and exposed edge.
Kit presence retains the panel through its exit. Translation and backdrop fading use the theme's normal duration and an ease-out curve; reversing an active transition starts at its current position. OS and theme reduced-motion preferences remove the travel. Focus stays trapped until the exit completes, then returns to the trigger. Window-level pointer capture keeps grip drags alive over content; short drags settle back and drags past a quarter of the panel dismiss it.
The native checks cover initial closed state, interrupted entry/exit, focus return, Tab trapping, four-edge sizing, a long scroll body with a visible footer, reduced motion and swipe dismissal. Browser checks exercise all four layouts, short-drag settling, vertical/horizontal swipes, Escape and backdrop dismissal.
Swipe handling is grip-only, leaving body text selection and scrolling intact. Snap points, nested stacking, non-modal drawers and velocity-based flings are not part of this API. Mobile OS gesture behavior and screen-reader parity remain unqualified. See migration qualification.