Skip to content
Domphy

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

PropTypeDescription
header{ icon?: string; title?: string; subtitle?: string }
searchPlaceholderstring
navGroupsSidebar05NavGroup[]
breadcrumbItemsSidebarBreadcrumbItem[]
childrenDomphyElement | 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 };

← Back to shadcn/ui catalog