sidebar05
A Sidebar block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call sidebar05() with no arguments for a working demo, or edit the code below live.
Props
| Prop | Type | Description |
|---|---|---|
header | { icon?: string; title?: string; subtitle?: string } | — |
searchPlaceholder | string | — |
navGroups | Sidebar05NavGroup[] | — |
breadcrumbItems | SidebarBreadcrumbItem[] | — |
children | DomphyElement | DomphyElement[] | — |
Implementation notes
Inline accordion sidebar. Plus/minus toggle glyph swapped purely via the details[open] CSS attribute selector (no JS, no rotation) — matches the spec's key visual signature. Multiple groups can be open simultaneously (native <details> behavior), one group open by default. Sub-list open/close animates via max-height/opacity transition (~180ms linear). Sidebar hide/show is a width transition + overflow on desktop; on narrow viewports (@media max-width) it becomes a position transform-slide overlay with a semi-transparent backdrop. Active sub-link gets accent-tinted background (listItemButton() patch) + bold text (nested strong() patch). Doctor self-check: 0 issues.
Status: ported · Reference: shadcn/ui original
// shadcn/ui "sidebar-05" — clean-room reimplementation from the public behavior
// description only (no upstream source viewed). Inline-accordion nav: each
// top-level group expands/collapses in place with a plus/minus toggle glyph
// (not a rotating chevron), multiple groups can stay open at once, and the
// sidebar itself slides fully off-canvas when hidden (no icon-rail collapse).
import type { DomphyElement, Listener } from "@domphy/core";
import { rawHtml, toState } from "@domphy/core";
import { themeColor, themeDensity, themeSpacing } from "@domphy/theme";
import {
icon,
inputSearch,
list,
listItemButton,
small,
strong,
} from "@domphy/ui";
import {
glyphChild,
ICON_MARK,
ICON_MINUS,
ICON_PLUS,
ICON_SEARCH,
type SidebarBreadcrumbItem,
sidebarBackdrop,
sidebarMainContent,
sidebarStickyHeader,
srOnlyLabel,
} from "./sidebar05-08-shared.js";
/** One nested link under a top-level nav group. */
type Sidebar05SubItem = {
title: string;
href?: string;
active?: boolean;
};
/** One collapsible top-level nav category. */
type Sidebar05NavGroup = {
title: string;
href?: string;
defaultOpen?: boolean;
items: Sidebar05SubItem[];
};
type Sidebar05Props = {
header?: { icon?: string; title?: string; subtitle?: string };
searchPlaceholder?: string;
navGroups?: Sidebar05NavGroup[];
breadcrumbItems?: SidebarBreadcrumbItem[];
children?: DomphyElement | DomphyElement[];
};
const DEFAULT_NAV_GROUPS: Sidebar05NavGroup[] = [
{
title: "Getting Started",
items: [
{ title: "Installation", href: "#" },
{ title: "Project Structure", href: "#" },
],
},
{
title: "Build Your Application",
defaultOpen: true,
items: [
{ title: "Routing", href: "#" },
{ title: "Data Fetching", href: "#", active: true },
{ title: "Rendering", href: "#" },
{ title: "Caching", href: "#" },
{ title: "Styling", href: "#" },
{ title: "Optimizing", href: "#" },
{ title: "Configuring", href: "#" },
{ title: "Testing", href: "#" },
{ title: "Authentication", href: "#" },
{ title: "Deploying", href: "#" },
{ title: "Upgrading", href: "#" },
{ title: "Examples", href: "#" },
],
},
{
title: "API Reference",
items: [
{ title: "Components", href: "#" },
{ title: "File Conventions", href: "#" },
{ title: "Functions", href: "#" },
{ title: "next.config.js Options", href: "#" },
{ title: "CLI", href: "#" },
{ title: "Edge Runtime", href: "#" },
],
},
{
title: "Architecture",
items: [
{ title: "Accessibility", href: "#" },
{ title: "Fast Refresh", href: "#" },
{ title: "Next.js Compiler", href: "#" },
{ title: "Supported Browsers", href: "#" },
{ title: "Turbopack", href: "#" },
],
},
{
title: "Community",
items: [{ title: "Contribution Guide", href: "#" }],
},
];
/** A single sub-link row: plain text, or bold + accent-tinted when active. */
function renderSubItem(
item: Sidebar05SubItem,
key: string | number,
): DomphyElement<"li"> {
return {
li: [
{
a: item.active
? ([
{
strong: item.title,
// strong() paints an inherit-tone background; keep it
// transparent so no box appears over the active pill.
style: { backgroundColor: "transparent" },
$: [strong({ color: "neutral" })],
},
] as unknown as DomphyElement)
: item.title,
href: item.href ?? "#",
ariaCurrent: item.active ? "page" : undefined,
$: [
listItemButton({
color: "neutral",
accentColor: "neutral",
dense: true,
}),
],
} as unknown as DomphyElement,
],
_key: key,
} as DomphyElement<"li">;
}
/** A collapsible top-level group: native <details> disclosure with a
* plus/minus toggle glyph swapped purely via the `[open]` CSS attribute
* selector (no JS, no rotating chevron). */
function renderNavGroup(
group: Sidebar05NavGroup,
key: string | number,
): DomphyElement<"li"> {
const subItems = group.items.map((item, index) =>
renderSubItem(item, `${group.title}-${index}`),
);
return {
li: [
{
details: [
{
summary: [
{
span: group.title,
style: { flex: "1", textAlign: "left" },
} as unknown as DomphyElement,
{
span: rawHtml(ICON_PLUS),
dataSlot: "toggle-plus",
$: [icon({ color: "neutral" })],
} as unknown as DomphyElement,
{
span: rawHtml(ICON_MINUS),
dataSlot: "toggle-minus",
$: [icon({ color: "neutral" })],
} as unknown as DomphyElement,
],
style: {
listStyle: "none",
cursor: "pointer",
userSelect: "none",
display: "flex",
alignItems: "center",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 2),
width: "100%",
paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 2),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
backgroundColor: (l: Listener) =>
themeColor(l, "inherit", "neutral"),
"&::-webkit-details-marker": { display: "none" },
"&::marker": { content: `""` },
"&:hover": {
backgroundColor: (l: Listener) =>
themeColor(l, "shift-2", "neutral"),
},
},
} as unknown as DomphyElement,
{
ul: subItems,
style: {
listStyle: "none",
margin: "0",
display: "flex",
flexDirection: "column",
gap: themeSpacing(0.5),
marginInlineStart: themeSpacing(5),
paddingInlineStart: themeSpacing(3),
paddingBlock: "0",
paddingInlineEnd: "0",
borderInlineStart: (l: Listener) =>
`1px solid ${themeColor(l, "shift-3", "neutral")}`,
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
maxHeight: "0",
overflow: "hidden",
opacity: "0",
transition: "max-height 180ms linear, opacity 180ms linear",
},
} as unknown as DomphyElement,
],
open: group.defaultOpen ?? false,
style: {
"&[open] [data-slot=toggle-plus]": { display: "none" },
"&:not([open]) [data-slot=toggle-minus]": { display: "none" },
"&[open] > ul": {
maxHeight: themeSpacing(240),
opacity: "1",
paddingBlock: themeSpacing(1),
},
},
} as unknown as DomphyElement,
],
_key: key,
} as DomphyElement<"li">;
}
/**
* shadcn/ui "sidebar-05" — inline-accordion collapsible nav with a plus/minus
* toggle glyph, a search field in the header, and a sticky breadcrumb header
* for the content area. Call with no arguments for a fully working demo.
*/
function sidebar05(props: Sidebar05Props = {}): DomphyElement<"div"> {
const {
header = { icon: ICON_MARK, title: "Documentation", subtitle: "v1.0.0" },
searchPlaceholder = "Search the docs...",
navGroups = DEFAULT_NAV_GROUPS,
breadcrumbItems = [
{ label: "Build Your Application" },
{ label: "Data Fetching" },
],
children,
} = props;
const sidebarOpen = toState(true);
const asideElement: DomphyElement<"aside"> = {
aside: [
{
div: [
{
a: [
{
span: glyphChild(header.icon ?? ICON_MARK),
dataTone: "shift-17",
style: {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: themeSpacing(8),
height: themeSpacing(8),
flexShrink: "0",
borderRadius: (l: Listener) =>
themeSpacing(themeDensity(l) * 2),
backgroundColor: (l: Listener) =>
themeColor(l, "inherit", "neutral"),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
},
} as unknown as DomphyElement,
{
div: [
{
strong: header.title ?? "Documentation",
$: [strong({ color: "neutral" })],
} as unknown as DomphyElement,
{
small: header.subtitle ?? "v1.0.0",
$: [small({ color: "neutral" })],
} as unknown as DomphyElement,
],
style: {
display: "flex",
flexDirection: "column",
gap: themeSpacing(0.5),
minWidth: "0",
},
} as unknown as DomphyElement,
],
href: "#",
style: {
display: "flex",
alignItems: "center",
gap: (l: Listener) => themeSpacing(themeDensity(l) * 3),
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 2),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 2),
textDecoration: () => "none",
"&:hover": {
backgroundColor: (l: Listener) =>
themeColor(l, "shift-2", "neutral"),
},
},
} as unknown as DomphyElement,
{
div: [
{
span: rawHtml(ICON_SEARCH),
style: {
position: "absolute",
insetInlineStart: themeSpacing(3),
top: "50%",
transform: "translateY(-50%)",
pointerEvents: "none",
display: "inline-flex",
color: (l: Listener) => themeColor(l, "shift-6", "neutral"),
},
} as unknown as DomphyElement,
srOnlyLabel("Search", "sidebar05-search"),
{
input: null,
id: "sidebar05-search",
type: "search",
ariaLabel: "Search",
placeholder: searchPlaceholder,
style: { width: "100%", paddingInlineStart: themeSpacing(9) },
$: [inputSearch({ color: "neutral", accentColor: "neutral" })],
} as unknown as DomphyElement,
],
style: {
position: "relative",
display: "flex",
alignItems: "center",
},
} as unknown as DomphyElement,
],
style: {
display: "flex",
flexDirection: "column",
gap: themeSpacing(4),
flexShrink: "0",
padding: (l: Listener) => themeSpacing(themeDensity(l) * 4),
},
} as unknown as DomphyElement,
{
nav: [
{
ul: navGroups.map((group, index) =>
renderNavGroup(group, group.title ?? index),
),
$: [list()],
} as unknown as DomphyElement,
],
style: {
flex: "1",
minHeight: "0",
overflowY: "auto",
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
paddingBottom: (l: Listener) => themeSpacing(themeDensity(l) * 4),
},
} as unknown as DomphyElement,
],
dataTone: "shift-1",
style: {
display: "flex",
flexDirection: "column",
flexShrink: "0",
width: (l: Listener) => (sidebarOpen.get(l) ? themeSpacing(64) : "0"),
overflow: "hidden",
transition: "width 0.2s linear",
borderInlineEnd: (l: Listener) =>
`1px solid ${themeColor(l, "shift-3", "neutral")}`,
backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
"@media (max-width: 768px)": {
position: "fixed",
insetBlock: "0",
insetInlineStart: "0",
zIndex: "15",
width: themeSpacing(72),
transform: (l: Listener) =>
sidebarOpen.get(l) ? "translateX(0)" : "translateX(-100%)",
transition: "transform 0.2s ease",
boxShadow: (l: Listener) =>
`0 0 ${themeSpacing(6)} ${themeColor(l, "shift-3", "neutral")}`,
},
},
} as unknown as DomphyElement<"aside">;
const mainElement: DomphyElement<"main"> = {
main: [
sidebarStickyHeader({
onToggle: () => sidebarOpen.set(!sidebarOpen.get()),
breadcrumbItems,
}),
sidebarMainContent(children),
],
style: {
display: "flex",
flexDirection: "column",
flex: "1",
minWidth: "0",
minHeight: "0",
overflow: "auto",
},
} as unknown as DomphyElement<"main">;
return {
div: [
asideElement,
mainElement,
sidebarBackdrop(sidebarOpen, () => sidebarOpen.set(false)),
],
style: {
display: "flex",
height: "100dvh",
overflow: "hidden",
position: "relative",
},
} as unknown as DomphyElement<"div">;
}
export { sidebar05 };
export type { Sidebar05NavGroup, Sidebar05Props, Sidebar05SubItem };