Component

sheet

dx components add sheet

A sheet component as an edge panel that complements the main content

Installation

Use the CLI command for the common path, or copy the component files manually.

Manual installation files

Copy the component source and CSS into your app. Import the shared theme CSS once near your app root.

use dioxus::prelude::*;
use dioxus_icons::lucide::X;
use dioxus_primitives::dioxus_attributes::attributes;
use dioxus_primitives::dialog::{
    self, DialogCtx, DialogDescriptionProps, DialogRootProps, DialogTitleProps,
};
use dioxus_primitives::merge_attributes;

#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum SheetSide {
    Top,
    #[default]
    Right,
    Bottom,
    Left,
}

impl SheetSide {
    pub fn as_str(&self) -> &'static str {
        match self {
            SheetSide::Top => "top",
            SheetSide::Right => "right",
            SheetSide::Bottom => "bottom",
            SheetSide::Left => "left",
        }
    }
}

#[component]
pub fn Sheet(props: DialogRootProps) -> Element {
    let content_base = attributes!(div {
        class: "dx-sheet",
        "data-slot": "sheet-content",
        "data-side": SheetSide::Right.as_str(),
    });
    let content_attributes = merge_attributes(vec![content_base, props.attributes]);

    rsx! {
        document::Link { rel: "stylesheet", href: asset!("/src/components/sheet/style.css") }
        dialog::DialogRoot {
            class: "dx-sheet-root",
            "data-slot": "sheet-root",
            id: props.id,
            is_modal: props.is_modal,
            open: props.open,
            default_open: props.default_open,
            on_open_change: props.on_open_change,
            dialog::DialogContent {
                class: None,
                attributes: content_attributes,
                {props.children}
            }
        }
    }
}

#[component]
pub fn SheetContentClose(#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>) -> Element {
    // axe `button-name` (docs/backlog.md row 34's own round): this button's
    // only content is the `X` icon, with no text and no accessible name --
    // mirrors the fix already applied per-call-site for `DialogClose`/
    // `AlertDialogClose` (`dialog/variants/main/mod.rs`'s `aria_label:
    // "Close"`), baked in here instead since both current call sites
    // (`sheet/variants/main/mod.rs`, `sidebar/component.rs`) render this
    // shared wrapper with no children of their own to derive a name from.
    let base = attributes!(button {
        class: "dx-sheet-close",
        aria_label: "Close",
    });
    let attributes = merge_attributes(vec![base, attributes]);

    rsx! {
        document::Link { rel: "stylesheet", href: asset!("/src/components/sheet/style.css") }
        SheetClose { attributes,
            // shadcn's close icon is `size-4` (16px) -- style.css's own
            // `.dx-sheet-close` comment has the full rationale for keeping
            // the 24px button box around it as a WCAG 2.5.8 hit target.
            X { size: "16px" }
        }
    }
}

#[component]
pub fn SheetHeader(
    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
    children: Element,
) -> Element {
    rsx! {
        document::Link { rel: "stylesheet", href: asset!("/src/components/sheet/style.css") }
        div { class: "dx-sheet-header", "data-slot": "sheet-header", ..attributes, {children} }
    }
}

#[component]
pub fn SheetFooter(
    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
    children: Element,
) -> Element {
    rsx! {
        document::Link { rel: "stylesheet", href: asset!("/src/components/sheet/style.css") }
        div { class: "dx-sheet-footer", "data-slot": "sheet-footer", ..attributes, {children} }
    }
}

#[component]
pub fn SheetTitle(props: DialogTitleProps) -> Element {
    rsx! {
        document::Link { rel: "stylesheet", href: asset!("/src/components/sheet/style.css") }
        dialog::DialogTitle {
            id: props.id,
            class: "dx-sheet-title",
            "data-slot": "sheet-title",
            attributes: props.attributes,
            {props.children}
        }
    }
}

#[component]
pub fn SheetDescription(props: DialogDescriptionProps) -> Element {
    rsx! {
        document::Link { rel: "stylesheet", href: asset!("/src/components/sheet/style.css") }
        dialog::DialogDescription {
            id: props.id,
            class: "dx-sheet-description",
            "data-slot": "sheet-description",
            attributes: props.attributes,
            {props.children}
        }
    }
}

// No `document::Link` here (unlike this file's other exported components):
// `SheetClose` reads `DialogCtx` via `use_context()` below, so it can only
// ever render as a descendant of a `dialog::DialogRoot` -- and in this file
// that context is provided by `Sheet` alone, which already links the sheet
// stylesheet. A context lookup failure would panic before this component
// could render unstyled, so there's no code path where `SheetClose` reaches
// the DOM without `Sheet`'s own `Link` already in the document head. It also
// has no single `rsx!` block both branches share (the `r#as` branch returns
// straight from the caller's own `Callback`), so adding a `Link` here would
// mean wrapping each branch individually -- unlike this file's other parts,
// that's more than a mechanical one-line insertion for a guarantee the
// context dependency already gives for free.
#[component]
pub fn SheetClose(
    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
    r#as: Option<Callback<Vec<Attribute>, Element>>,
    children: Element,
) -> Element {
    let ctx: DialogCtx = use_context();

    let base = attributes! {
        button {
            onclick: move |_| {
                ctx.set_open(false);
            }
        }
    };
    let merged = merge_attributes(vec![base, attributes]);

    if let Some(dynamic) = r#as {
        dynamic.call(merged)
    } else {
        rsx! {
            button { ..merged, {children} }
        }
    }
}

Usage notes

The sheet component is a panel that slides in from the edge of the screen. It can be used to display additional content, forms, or navigation menus without leaving the current page.

Component Structure

Sheet {
    open: open(),
    // Which edge to slide in from. Available sides: Top, Right (default), Bottom, Left.
    "data-side": SheetSide::Right.as_str(),
    SheetContentClose {}
    SheetHeader {
        SheetTitle { "Edit profile" }
        SheetDescription { "Make changes to your profile here. Click save when you're done." }
    }
    SheetFooter {
        SheetClose { "Close" }
    }
}

SheetClose with as prop

The as prop allows you to render a custom element while preserving the close behavior, similar to shadcn/ui's asChild pattern.

// Default: renders as <button>
SheetClose { "Close" }

// Custom element: attributes include the preset onclick handler
SheetClose {
    as: |attributes| rsx! {
        a { href: "#", ..attributes, "Go back" }
    }
}

Alignment with shadcn/ui v4

This component's style.css was checked line-by-line against shadcn/ui v4's Sheet (SheetOverlay/SheetContent/SheetHeader/SheetFooter/ SheetTitle/SheetDescription/SheetPrimitive.Close) and brought in line with it, translated onto this repo's own design tokens rather than copying Tailwind classes verbatim:

  • Overlay: fixed inset-0 bg-black/50. On the web build, .dx-sheet is a real <dialog> (primitives/src/dialog.rs's modal web arm), so the visible tint moved to its native ::backdrop pseudo-element there; the non-web (Blitz) arm has no such element, so .dx-sheet-root keeps the tint for that arm. Both are driven by the same bg-black/50 value and the same fade keyframes.
  • Content: fixed z-50 flex flex-col gap-4 bg-background shadow-lg, sliding in from its data-side with transition ease-in-out, a 500ms open / 300ms close duration (this repo's motion scale has no exact 500ms step, so that one value is a literal; 300ms matches --dx-motion-duration-slower exactly). right/left are inset-y-0 h-full w-3/4 border-l|border-r, capped at sm:max-w-sm (384px) only from a 640px viewport up, matching shadcn's own breakpoint-gated cap rather than applying it unconditionally. top/bottom are inset-x-0 h-auto border-b|border-t.
  • Header/Footer/Title/Description: flex flex-col gap-1.5 p-4 / mt-auto flex flex-col gap-2 p-4 / text-foreground font-semibold / text-muted-foreground text-sm. Title has no explicit text-size class in shadcn's own source (unlike DialogTitle's text-lg); since this repo has no global heading-reset the way Tailwind's preflight does, that translates to an explicit --dx-text-base here rather than an omitted font-size, which would otherwise fall back to the browser's own, much larger, default <h2> size.
  • Close button: absolute top-4 right-4 rounded-xs opacity-70 hover:opacity-100, a --dx-ring focus-visible ring, and a size-4 (16px) icon with an aria-label (this repo's equivalent of shadcn's visually-hidden "Close" span). The button's own hit target stays a fixed 24x24px box around that icon, larger than shadcn's own (icon-sized, no padding) -- a deliberate, pre-existing choice in this codebase favoring WCAG 2.5.8's 24px target-size guidance over byte-for-byte fidelity here.
  • Native <dialog> centering defect (shared with Drawer): Chromium's dialog:modal UA stylesheet ships inset: 0; margin: auto; width/height: fit-content. Overriding only the one edge a given side needs (e.g. right: 0) used to leave the UA's own inset: 0 still supplying the opposite edge uncontested, over-constraining the box against a definite width/height -- the UA resolved that by centering the panel into its own auto margins rather than anchoring it (confirmed live: a right/left sheet rendered as a floating centered card, and a top/bottom sheet stretched to the full viewport height instead of sizing to its content). Fixed by resetting margin/inset/width/height to a known, non-auto baseline on .dx-sheet itself and letting each [data-side] rule reopen (set back to auto) exactly the one edge it doesn't pin.

See dev-docs/backlog.md for the session this alignment pass landed in.