sidebarInDialog
A Sidebar block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call sidebarInDialog() with no arguments for a working demo, or edit the code below live.
Props
| Prop | Type | Description |
|---|---|---|
categories | SettingsCategory[] | — |
defaultCategoryId | string | Category selected when the dialog first opens. Defaults to the researched default ("messages"). |
renderContent | (categoryId: string) => DomphyElement | DomphyElement[] | Per-category body renderer. Defaults to 10 stacked skeleton placeholder rows. |
open | ValueOrState<boolean> | Dialog open state — pass a State<boolean> for controlled usage. Defaults to open (matches upstream useState(true)). |
title | string | Accessible dialog title (also used as the breadcrumb root segment). |
description | string | Accessible dialog description (visually hidden, read by screen readers only). |
triggerLabel | string | Content of the button that opens the dialog. |
Implementation notes
Settings-style modal (trigger button + <dialog>) with an embedded two-pane layout. Nav-column visibility uses a CSS @container query (containerType:'inline-size' on the dialog) rather than @media, so 'hidden on narrow dialog widths' tracks the dialog's own rendered width, not the viewport, matching the spec literally. Fade+scale entrance: the base dialog() patch only animates opacity (rAF-deferred so the browser paints the 0-state before transitioning), so a second _onMount hook was composed on the same element (Domphy hooks compose via addHook, don't override) driving transform: scale() on the same open state with the same rAF-deferral technique, keeping dialog()'s open/close/focus-trap/scroll-lock/Escape mechanics as the single source of truth. Category content swap is instant (style.display toggle keyed off active-category id) with a ~100ms background-color transition on the active nav row for polish, matching the 'no animation on selection' note. Default content renderer produces the same generic 10-skeleton-row body for every category (per researchNote: 'not a fixed requirement'); callers can override per-category via renderContent(categoryId).
Status: ported · Reference: shadcn/ui original
// shadcn/ui "sidebar-in-dialog" block — a settings-style modal: a trigger
// opens a centered dialog that embeds its own compact two-pane layout — a
// narrow category list on the left (hidden below the 768px breakpoint, same
// as upstream's `hidden md:flex`) and a scrollable content pane on the right
// whose header/body swap instantly when a category is selected. This is a
// self-contained trigger+dialog pair, not a page shell like the other
// sidebar-0N blocks.
import type {
DomphyElement,
Listener,
State,
ValueOrState,
} from "@domphy/core";
import { behavior, rawHtml, toState } from "@domphy/core";
import { themeColor, themeDensity, themeSpacing } from "@domphy/theme";
import {
breadcrumb,
button,
dialog,
icon,
skeleton,
small,
strong,
} from "@domphy/ui";
/** One row in the settings category list. */
interface SettingsCategory {
id: string;
label: string;
/** Raw inline SVG markup (24x24, stroke=currentColor), same convention as the rest of the family. */
icon: string;
}
type SidebarInDialogProps = {
categories?: SettingsCategory[];
/** Category selected when the dialog first opens. Defaults to the researched default ("messages"). */
defaultCategoryId?: string;
/** Per-category body renderer. Defaults to 10 stacked skeleton placeholder rows. */
renderContent?: (categoryId: string) => DomphyElement | DomphyElement[];
/** Dialog open state — pass a `State<boolean>` for controlled usage. Defaults to open (matches upstream `useState(true)`). */
open?: ValueOrState<boolean>;
/** Accessible dialog title (also used as the breadcrumb root segment). */
title?: string;
/** Accessible dialog description (visually hidden, read by screen readers only). */
description?: string;
/** Content of the button that opens the dialog. */
triggerLabel?: string;
};
// ---------------------------------------------------------------------------
// Hand-authored generic line icons (24x24, stroke=currentColor) — simple
// geometric shapes, not sourced from any icon library.
// ---------------------------------------------------------------------------
const ICON_BELL =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M6 8a6 6 0 0 1 12 0c0 4 1.5 5.5 2 6H4c.5-.5 2-2 2-6z"/><path d="M9.5 18a2.5 2.5 0 0 0 5 0"/></svg>';
const ICON_MENU =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>';
const ICON_HOME =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M4 11l8-7 8 7"/><path d="M6 10v9a1 1 0 0 0 1 1h4v-6h2v6h4a1 1 0 0 0 1-1v-9"/></svg>';
const ICON_APPEARANCE =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M9.5 16.5 18 8a2 2 0 0 0-2-2l-8.5 8.5z"/><path d="M4 20c0-2.2 1.3-4 3-4s3 1.3 3 3-1.4 3-3.2 3c-1.6 0-2.8-.8-2.8-2z"/></svg>';
const ICON_MESSAGE =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M4 5h16v11H8l-4 4z"/></svg>';
const ICON_GLOBE =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/></svg>';
const ICON_ACCESSIBILITY =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8.5" r="1.4"/><path d="M8 11.5l4 1 4-1M12 12.5v3.5M9.7 19.5l2.3-3.5 2.3 3.5"/></svg>';
const ICON_CHECK =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M5 12l5 5 9-10"/></svg>';
const ICON_AV =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><rect x="2" y="6" width="14" height="12" rx="2"/><path d="M16 12 22 8v8z"/></svg>';
const ICON_CONNECTED =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M9 15l6-6"/><path d="M8 12l-2 2a3 3 0 0 0 4 4l2-2"/><path d="M16 12l2-2a3 3 0 0 0-4-4l-2 2"/></svg>';
const ICON_LOCK =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><rect x="4" y="11" width="16" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
const ICON_SETTINGS =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><circle cx="12" cy="12" r="3.5"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9 17 7M7 17l-2.1 2.1"/></svg>';
const ICON_CLOSE =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="1em" height="1em"><path d="M6 6l12 12M18 6L6 18"/></svg>';
const DEFAULT_CATEGORIES: SettingsCategory[] = [
{ id: "notifications", label: "Notifications", icon: ICON_BELL },
{ id: "navigation", label: "Navigation", icon: ICON_MENU },
{ id: "home", label: "Home", icon: ICON_HOME },
{ id: "appearance", label: "Appearance", icon: ICON_APPEARANCE },
{ id: "messages", label: "Messages & media", icon: ICON_MESSAGE },
{ id: "language", label: "Language & region", icon: ICON_GLOBE },
{ id: "accessibility", label: "Accessibility", icon: ICON_ACCESSIBILITY },
{ id: "mark-read", label: "Mark as read", icon: ICON_CHECK },
{ id: "audio-video", label: "Audio & video", icon: ICON_AV },
{ id: "connected", label: "Connected accounts", icon: ICON_CONNECTED },
{ id: "privacy", label: "Privacy & visibility", icon: ICON_LOCK },
{ id: "advanced", label: "Advanced", icon: ICON_SETTINGS },
];
const SR_ONLY_STYLE = {
position: "absolute",
width: "1px",
height: "1px",
padding: "0",
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
border: "0",
} as const;
const DESCRIPTION_ID = "sidebar-in-dialog-description";
/** Ten stacked rounded placeholder blocks — stand-ins for real settings fields. */
function defaultCategoryContent(): DomphyElement<"div">[] {
return Array.from({ length: 10 }, (_unused, index) => ({
div: null,
_key: `field-${index}`,
$: [skeleton()],
style: {
height: themeSpacing(10),
width: index % 3 === 2 ? "60%" : "100%",
},
})) as DomphyElement<"div">[];
}
/** A single category row: icon + label, soft-highlighted background while active. */
function categoryRow(
category: SettingsCategory,
activeCategoryId: ReturnType<typeof toState<string>>,
onSelect: (id: string) => void,
): DomphyElement<"li"> {
return {
li: [
{
button: [
{
span: rawHtml(category.icon),
$: [icon({ color: "neutral" })],
} as unknown as DomphyElement,
{
span: category.label,
style: { flex: "1", textAlign: "left" },
} as unknown as DomphyElement,
],
type: "button",
ariaCurrent: (l: Listener) =>
activeCategoryId.get(l) === category.id ? "true" : undefined,
onClick: () => onSelect(category.id),
style: {
display: "flex",
alignItems: "center",
width: "100%",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 2),
paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 2),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
border: "none",
cursor: "pointer",
textAlign: "left",
transition: "background-color 100ms ease",
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
"&:hover": {
backgroundColor: (l: Listener) =>
themeColor(l, "shift-2", "neutral"),
},
"&[aria-current=true]": {
backgroundColor: (l: Listener) =>
themeColor(l, "shift-3", "neutral"),
// Text must sit at distance 9 from the shift-3 active fill (the
// K=9 contrast span) — shift-11 measured below WCAG AA here (axe
// color-contrast), so the active label rests at shift-12.
color: (l: Listener) => themeColor(l, "shift-12", "neutral"),
},
},
} as unknown as DomphyElement,
],
_key: category.id,
} as DomphyElement<"li">;
}
/**
* shadcn/ui "sidebar-in-dialog" — a settings dialog with its own embedded
* two-pane layout: a compact category list on the left, matching scrollable
* content on the right. Category selection is a pure local state change (no
* navigation, no content-swap animation). Call with no arguments for a fully
* working demo (trigger button + dialog, 12 default categories).
*/
function sidebarInDialog(
props: SidebarInDialogProps = {},
): DomphyElement<"div"> {
const {
categories = DEFAULT_CATEGORIES,
title = "Settings",
description = "Manage your account settings and preferences.",
triggerLabel = "Open settings",
} = props;
const defaultCategoryId =
props.defaultCategoryId ??
categories.find((category) => category.id === "messages")?.id ??
categories[0]?.id ??
"";
const renderContent = props.renderContent ?? (() => defaultCategoryContent());
const open = toState(props.open ?? true);
const activeCategoryId = toState(defaultCategoryId);
const currentLabel = (l: Listener): string =>
categories.find((category) => category.id === activeCategoryId.get(l))
?.label ?? "";
const navColumn: DomphyElement<"nav"> = {
nav: [
{
ul: categories.map((category) =>
categoryRow(category, activeCategoryId, (id) =>
activeCategoryId.set(id),
),
),
style: {
listStyle: "none",
margin: "0",
padding: "0",
display: "flex",
flexDirection: "column",
gap: themeSpacing(0.5),
},
} as unknown as DomphyElement,
],
ariaLabel: "Settings categories",
style: {
display: "none",
flexShrink: "0",
width: themeSpacing(64),
overflowY: "auto",
padding: (l: Listener) => themeSpacing(themeDensity(l) * 3),
borderInlineEnd: (l: Listener) =>
`1px solid ${themeColor(l, "shift-3", "neutral")}`,
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
"@media (min-width: 768px)": { display: "flex", flexDirection: "column" },
},
} as unknown as DomphyElement<"nav">;
const headerBar: DomphyElement<"div"> = {
div: [
{
nav: [
{
small: title,
$: [small({ color: "neutral" })],
style: { "@media (max-width: 767px)": { display: "none" } },
} as unknown as DomphyElement,
{
strong: (l: Listener) => currentLabel(l),
ariaCurrent: "page",
$: [strong({ color: "neutral" })],
} as unknown as DomphyElement,
],
$: [breadcrumb({ color: "neutral", separator: "›" })],
} as unknown as DomphyElement,
{
button: {
span: rawHtml(ICON_CLOSE),
$: [icon({ color: "neutral" })],
} as unknown as DomphyElement,
type: "button",
ariaLabel: "Close settings",
onClick: () => open.set(false),
style: {
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: "0",
border: "none",
cursor: "pointer",
width: themeSpacing(8),
height: themeSpacing(8),
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
"&:hover": {
backgroundColor: (l: Listener) =>
themeColor(l, "shift-2", "neutral"),
},
},
} as unknown as DomphyElement,
],
style: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexShrink: "0",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 3),
height: themeSpacing(16),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 4),
borderBottom: (l: Listener) =>
`1px solid ${themeColor(l, "shift-3", "neutral")}`,
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
},
} as unknown as DomphyElement<"div">;
const bodyScroll: DomphyElement<"div"> = {
div: categories.map((category) => ({
div: renderContent(category.id),
_key: category.id,
style: {
display: (l: Listener) =>
activeCategoryId.get(l) === category.id ? "flex" : "none",
flexDirection: "column",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 3),
},
})) as unknown as DomphyElement[],
style: {
flex: "1",
minHeight: "0",
overflowY: "auto",
// upstream `p-4 pt-0`: 16px sides/bottom, 0 top (header owns the top gap).
paddingTop: "0",
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 4),
paddingBottom: (l: Listener) => themeSpacing(themeDensity(l) * 4),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
},
} as unknown as DomphyElement<"div">;
const contentColumn: DomphyElement<"div"> = {
div: [headerBar, bodyScroll],
style: {
display: "flex",
flexDirection: "column",
flex: "1",
minWidth: "0",
overflow: "hidden",
},
} as unknown as DomphyElement<"div">;
const paneRow: DomphyElement<"div"> = {
div: [navColumn, contentColumn],
style: {
display: "flex",
flexDirection: "row",
flex: "1",
minHeight: "0",
overflow: "hidden",
},
} as unknown as DomphyElement<"div">;
const dialogElement: DomphyElement<"dialog"> = {
dialog: [
{
p: description,
id: DESCRIPTION_ID,
style: SR_ONLY_STYLE,
} as unknown as DomphyElement,
paneRow,
],
ariaLabel: title,
ariaDescribedby: DESCRIPTION_ID,
$: [dialog({ open, color: "neutral" })],
// Scale entrance/exit layered on top of the base patch's opacity fade —
// driven by the same `open` state, mirroring the patch's own rAF-deferred
// opacity toggle so both properties animate together. This adds a purely
// decorative style effect; the dialog patch itself owns all open/close
// mechanics (single source of truth for focus trap/scroll lock/escape).
// Declared as a behavior() so a reactive ancestor re-render re-subscribes
// the CURRENT generation's `open` state instead of leaving the transform
// synced to generation 1's disconnected one.
...behavior<{ open: State<boolean> }>(
"sidebar-dialog-scale",
(node, initialProps) => {
const element = node.domElement as HTMLDialogElement | null;
if (!element) return undefined;
let openState = initialProps.open;
const applyScale = (isOpen: boolean) => {
if (isOpen) {
requestAnimationFrame(() => {
element.style.transform = "scale(1)";
});
} else {
element.style.transform = "scale(0.95)";
}
};
applyScale(openState.get());
let release = openState.addListener(applyScale);
return {
update: (next) => {
if (next.open === openState) return;
release();
openState = next.open;
applyScale(openState.get());
release = openState.addListener(applyScale);
},
destroy: () => release(),
};
},
{ open },
),
style: {
display: "flex",
// The base `dialog()` patch relies on the native
// `dialog:not([open]) { display: none }` UA rule to hide the closed
// dialog — but that rule loses to this author-origin class regardless
// of specificity (author always beats user-agent), so forcing
// `display: flex` unconditionally left a closed dialog fully laid out
// (absolutely positioned, zero-opacity, but still `pointer-events:
// auto`) and able to intercept clicks meant for whatever sits behind
// it. Re-hiding on `:not([open])` — the same pattern sidebar05 already
// uses for a closed `<details>` — restores that contract without
// touching the flex layout while genuinely open (or mid-close, since
// the patch keeps `[open]` set through the fade-out transition).
"&:not([open])": { display: "none" },
flexDirection: "column",
padding: "0",
width: "92vw",
// upstream steps the cap: base sm:max-w-lg (512px), md:max-w-[700px],
// lg:max-w-[800px] — replicated as two min-width breakpoints.
maxWidth: themeSpacing(128),
"@media (min-width: 768px)": { maxWidth: themeSpacing(175) },
"@media (min-width: 1024px)": { maxWidth: themeSpacing(200) },
height: themeSpacing(125),
maxHeight: "88vh",
overflow: "hidden",
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 3),
transform: "scale(0.95)",
transition: "opacity 180ms ease-out, transform 180ms ease-out",
},
} as unknown as DomphyElement<"dialog">;
const triggerElement: DomphyElement<"button"> = {
button: triggerLabel,
type: "button",
onClick: () => open.set(true),
// Upstream default <Button> is near-black (primary), not brand blue.
dataTone: "shift-17",
$: [button({ color: "neutral" })],
// upstream `<Button size="sm">`: compact h-8 (32px), px-3 (kept from the
// patch's density*3 inline padding). Zero the patch's block padding so the
// fixed height governs, matching the sm variant's tighter footprint.
style: {
height: themeSpacing(8),
paddingBlock: "0",
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
} as unknown as DomphyElement<"button">;
return {
div: [triggerElement, dialogElement],
} as DomphyElement<"div">;
}
export { sidebarInDialog };
export type { SidebarInDialogProps, SettingsCategory };