Skip to content
Domphy

sidebarStickyHeader

A Sidebar block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call sidebarStickyHeader() with no arguments for a working demo, or edit the code below live.

Props

PropTypeDescription
brandSidebarTeamBrand icon/name/plan-caption shown at the top of the sidebar.
breadcrumbItemsSidebarBreadcrumbItem[]
searchPlaceholderstring
navMainSidebarNavMainItem[]
projectsSidebarProject[]
secondaryNavSidebarStickyHeaderSecondaryItem[]
userSidebarUser
childrenDomphyElement | DomphyElement[]
Implementation notes

Full-featured sidebar (brand header, nested Platform nav, Projects list, secondary nav, user footer) reusing sidebar05-08-shared.ts's row renderers (renderTeamSwitcher/renderExpandableNavRow/renderPlainNavRow/renderProjectRow/renderUserFooter/icons — all safe read-only imports), paired with a full-width site header that is a genuine sibling of the sidebar, not scrolled with it: position, with a --siteHeaderHeight CSS custom property (declared via a camelCase Domphy style key that the framework's camelToKebab converts to a valid --site-header-height custom property) that the sidebar/content row's marginBlockStart and height(100dvh - var(...)) read from, exactly matching the spec's explicit 'page wrapper exposes a header-height custom property' requirement; verified the custom-property round-trip via ElementNode.generateCSS() before relying on it. The brand header/Platform items/default dataset intentionally mirror the researchNote's stated shadcn demo values (Playground/Models/Documentation/Settings; Design Engineering/Sales & Marketing/Travel; user shadcn/m@example.com) as swappable placeholder data. A small 'secondary nav row' renderer is duplicated locally rather than importing it from sidebar08.ts, for the same concurrent-file-safety reason noted on sidebarLeftRight (sidebar05-08-shared.ts/sidebar08.ts were being actively edited by a concurrent process during this session) — a minor, deliberate code-duplication tradeoff, not a functional gap.

Status: ported · Reference: shadcn/ui original

// shadcn/ui "sidebar-16" block ("A sidebar with a header and a search form").
// A full-featured sidebar (plain brand link, nested Platform nav, Projects
// list, bottom-pinned secondary nav, user footer) paired with a full-width
// site header that is a SIBLING of the sidebar rather than nested inside its
// own `<main>` scroll container. The header sits `position: fixed` above
// everything, and a `--siteHeaderHeight` custom property on the page wrapper
// lets the sidebar/content row offset correctly beneath it.
//
// Collapse is OFFCANVAS (upstream `<Sidebar>` default `collapsible="offcanvas"`,
// AppSidebar passes no override): the header toggle slides the whole panel off
// the left edge and collapses its layout gap to zero — NOT the icon-rail mode
// of sidebar-07. The panel is `position: fixed` and a sibling spacer div
// reserves its width in the flow, mirroring shadcn's gap + fixed-container pair.
//
// A handful of details live in the shared ./sidebar05-08-shared.ts renderers
// (user-footer menu items, project-row "more" menu items, default main-content
// grid) and so are shared across sidebar variants rather than owned here.

import type { DomphyElement, Listener } from "@domphy/core";
import { rawHtml, toState } from "@domphy/core";
import { themeColor, themeDensity, themeSpacing } from "@domphy/theme";
import {
  breadcrumb,
  buttonGhost,
  inputSearch,
  link,
  small,
  strong,
} from "@domphy/ui";
import {
  glyphChild,
  ICON_BAR_CHART,
  ICON_FOLDER,
  ICON_GRID,
  ICON_INBOX,
  ICON_LIFEBUOY,
  ICON_MARK,
  ICON_MESSAGE,
  ICON_PANEL_TOGGLE,
  ICON_SEARCH,
  renderExpandableNavRow,
  renderPlainNavRow,
  renderProjectRow,
  renderProjectsMoreRow,
  renderUserFooter,
  type SidebarBreadcrumbItem,
  type SidebarNavMainItem,
  type SidebarProject,
  type SidebarTeam,
  type SidebarUser,
  sidebarBackdrop,
  sidebarIcon,
  sidebarMainContent,
  srOnlyLabel,
  verticalDivider,
} from "./sidebar05-08-shared.js";
import { sidebarHotkey } from "./sidebarHotkey.js";

/** A quiet utility link (Support/Feedback) — no active-state styling, always visible. */
type SidebarStickyHeaderSecondaryItem = {
  title: string;
  href?: string;
  icon?: string;
};

type SidebarStickyHeaderProps = {
  /** Brand icon/name/plan-caption shown at the top of the sidebar. */
  brand?: SidebarTeam;
  breadcrumbItems?: SidebarBreadcrumbItem[];
  searchPlaceholder?: string;
  navMain?: SidebarNavMainItem[];
  projects?: SidebarProject[];
  secondaryNav?: SidebarStickyHeaderSecondaryItem[];
  user?: SidebarUser;
  children?: DomphyElement | DomphyElement[];
};

const SITE_HEADER_HEIGHT = themeSpacing(16);

const DEFAULT_BRAND: SidebarTeam = {
  name: "Acme Inc",
  plan: "Enterprise",
  logo: ICON_MARK,
};

const DEFAULT_NAV_MAIN: SidebarNavMainItem[] = [
  {
    title: "Playground",
    icon: ICON_GRID,
    // Upstream marks the PARENT active (drives `defaultOpen`); no sub-item is
    // highlighted.
    active: true,
    items: [{ title: "History" }, { title: "Starred" }, { title: "Settings" }],
  },
  {
    title: "Models",
    icon: ICON_INBOX,
    items: [{ title: "Genesis" }, { title: "Explorer" }, { title: "Quantum" }],
  },
  {
    title: "Documentation",
    icon: ICON_BAR_CHART,
    items: [
      { title: "Introduction" },
      { title: "Get Started" },
      { title: "Tutorials" },
      { title: "Changelog" },
    ],
  },
  {
    title: "Settings",
    icon: ICON_FOLDER,
    items: [
      { title: "General" },
      { title: "Team" },
      { title: "Billing" },
      { title: "Limits" },
    ],
  },
];

const DEFAULT_PROJECTS: SidebarProject[] = [
  { title: "Design Engineering", icon: ICON_FOLDER, href: "#" },
  { title: "Sales & Marketing", icon: ICON_FOLDER, href: "#" },
  { title: "Travel", icon: ICON_FOLDER, href: "#" },
];

const DEFAULT_SECONDARY_NAV: SidebarStickyHeaderSecondaryItem[] = [
  { title: "Support", icon: ICON_LIFEBUOY, href: "#" },
  { title: "Feedback", icon: ICON_MESSAGE, href: "#" },
];

const DEFAULT_USER: SidebarUser = { name: "shadcn", email: "m@example.com" };

/** A de-emphasized secondary-nav row (upstream `SidebarMenuButton size="sm"`):
 * a small icon + label link, no active-state styling, no tooltip. */
function renderSecondaryNavRow(
  item: SidebarStickyHeaderSecondaryItem,
): DomphyElement<"li"> {
  return {
    li: [
      {
        a: [
          ...(item.icon ? [sidebarIcon(item.icon)] : []),
          {
            span: item.title,
            style: { flex: "1", textAlign: "left" },
          } as unknown as DomphyElement,
        ],
        href: item.href ?? "#",
        style: {
          display: "flex",
          alignItems: "center",
          width: "100%",
          gap: (l: Listener) => themeSpacing(themeDensity(l) * 2),
          paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 1.5),
          paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
          borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
          textDecoration: () => "none",
          overflow: "hidden",
          whiteSpace: "nowrap",
          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,
    ],
    _key: item.title,
  } as DomphyElement<"li">;
}

/** Brand header — a plain `<a>` link (icon badge + name/plan two-line label),
 * matching upstream `SidebarHeader`: no chevron and no team-switcher dropdown. */
function renderBrandHeader(brand: SidebarTeam): DomphyElement<"div"> {
  return {
    div: [
      {
        a: [
          {
            span: glyphChild(brand.logo ?? 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: brand.name,
                $: [strong({ color: "neutral" })],
              } as unknown as DomphyElement,
              {
                small: brand.plan,
                $: [small({ color: "neutral" })],
              } as unknown as DomphyElement,
            ],
            style: {
              display: "flex",
              flexDirection: "column",
              gap: themeSpacing(0.5),
              flex: "1",
              minWidth: "0",
              overflow: "hidden",
              whiteSpace: "nowrap",
              textAlign: "left",
            },
          } as unknown as DomphyElement,
        ],
        href: "#",
        style: {
          display: "flex",
          alignItems: "center",
          width: "100%",
          gap: (l: Listener) => themeSpacing(themeDensity(l) * 2),
          padding: (l: Listener) => themeSpacing(themeDensity(l) * 2),
          borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 1),
          textDecoration: () => "none",
          overflow: "hidden",
          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: { padding: (l: Listener) => themeSpacing(themeDensity(l) * 2) },
  } as unknown as DomphyElement<"div">;
}

/** Breadcrumb trail; the whole nav hides below a breakpoint, matching
 * upstream's `<Breadcrumb className="hidden sm:block">`. */
function stickyHeaderBreadcrumb(
  items: SidebarBreadcrumbItem[],
): DomphyElement<"nav"> {
  const crumbs: DomphyElement[] = items.map((item, index) => {
    const isLast = index === items.length - 1;
    if (isLast) {
      return {
        strong: item.label,
        _key: `${item.label}-${index}`,
        ariaCurrent: "page",
        $: [strong({ color: "neutral" })],
      } as unknown as DomphyElement;
    }
    return {
      a: item.label,
      _key: `${item.label}-${index}`,
      href: item.href ?? "#",
      $: [link({ color: "neutral", accentColor: "neutral" })],
    } as unknown as DomphyElement;
  });
  return {
    nav: crumbs,
    $: [breadcrumb({ color: "neutral" })],
    style: { "@media (max-width: 40em)": { display: "none" } },
  } as DomphyElement<"nav">;
}

/**
 * shadcn/ui "sidebar-sticky-header" — a full-featured collapsible sidebar
 * (brand header, nested Platform nav, Projects list, secondary nav, user
 * footer) below a full-width site header that is a sibling of the sidebar,
 * pinned above everything via `position: fixed` and coordinated with the
 * rest of the page through a `--siteHeaderHeight` custom property. Call with
 * no arguments for a fully working demo.
 */
function sidebarStickyHeader(
  props: SidebarStickyHeaderProps = {},
): DomphyElement<"div"> {
  const {
    brand = DEFAULT_BRAND,
    breadcrumbItems = [
      { label: "Build Your Application" },
      { label: "Data Fetching" },
    ],
    searchPlaceholder = "Type to search...",
    navMain = DEFAULT_NAV_MAIN,
    projects = DEFAULT_PROJECTS,
    secondaryNav = DEFAULT_SECONDARY_NAV,
    user = DEFAULT_USER,
    children,
  } = props;

  const sidebarOpen = toState(true);
  // sidebar-16 collapses OFFCANVAS (the whole panel slides away), never to an
  // icon rail. The shared row renderers still take a `collapsed` state to pick
  // their expanded-vs-icon variant; pass a constant `false` so they only ever
  // draw the expanded rows.
  const collapsed = toState(false);

  const navMainRows = navMain.map((item) =>
    item.items && item.items.length > 0
      ? renderExpandableNavRow(item, collapsed)
      : renderPlainNavRow(item, collapsed),
  );

  const siteHeader: DomphyElement<"header"> = {
    header: [
      {
        button: sidebarIcon(ICON_PANEL_TOGGLE),
        type: "button",
        ariaLabel: "Toggle sidebar",
        onClick: () => sidebarOpen.set(!sidebarOpen.get()),
        $: [buttonGhost({ color: "neutral" })],
      } as unknown as DomphyElement,
      verticalDivider(),
      stickyHeaderBreadcrumb(breadcrumbItems),
      { div: null, style: { flex: "1 1 auto" } } as unknown as DomphyElement,
      {
        form: [
          {
            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", "sidebar16-search"),
          {
            input: null,
            id: "sidebar16-search",
            type: "search",
            placeholder: searchPlaceholder,
            ariaLabel: "Search",
            style: { width: "100%", paddingInlineStart: themeSpacing(9) },
            $: [inputSearch({ color: "neutral", accentColor: "neutral" })],
          } as unknown as DomphyElement,
        ],
        role: "search",
        onSubmit: (e: Event) => e.preventDefault(),
        style: {
          position: "relative",
          display: "flex",
          alignItems: "center",
          width: "100%",
          maxWidth: themeSpacing(64),
          "@media (max-width: 40em)": { maxWidth: "100%" },
        },
      } as unknown as DomphyElement,
    ],
    style: {
      position: "fixed",
      insetBlockStart: "0",
      insetInline: "0",
      zIndex: "50",
      display: "flex",
      alignItems: "center",
      gap: (l: Listener) => themeSpacing(themeDensity(l) * 3),
      height: "var(--siteHeaderHeight)",
      paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 4),
      borderBottom: (l: Listener) =>
        `1px solid ${themeColor(l, "shift-3", "neutral")}`,
      backgroundColor: (l: Listener) => themeColor(l, "inherit", "neutral"),
      color: (l: Listener) => themeColor(l, "shift-9", "neutral"),
    },
  } as unknown as DomphyElement<"header">;

  const asideElement: DomphyElement<"aside"> = {
    aside: [
      renderBrandHeader(brand),
      {
        nav: [
          {
            small: "Platform",
            style: { paddingInline: themeSpacing(3) },
            $: [small({ color: "neutral" })],
          } as unknown as DomphyElement,
          {
            ul: navMainRows,
            style: {
              listStyle: "none",
              margin: "0",
              padding: "0",
              display: "flex",
              flexDirection: "column",
              gap: themeSpacing(0.5),
            },
          } as unknown as DomphyElement,
          {
            div: [
              {
                small: "Projects",
                style: { paddingInline: themeSpacing(3) },
                $: [small({ color: "neutral" })],
              } as unknown as DomphyElement,
              {
                ul: [
                  ...projects.map((project) =>
                    renderProjectRow(project, collapsed),
                  ),
                  renderProjectsMoreRow(),
                ],
                style: {
                  listStyle: "none",
                  margin: "0",
                  padding: "0",
                  display: "flex",
                  flexDirection: "column",
                  gap: themeSpacing(0.5),
                },
              } as unknown as DomphyElement,
            ],
            style: {
              display: "flex",
              flexDirection: "column",
              gap: themeSpacing(1),
              marginTop: themeSpacing(4),
            },
          } as unknown as DomphyElement,
          {
            // Upstream pins the secondary nav to the bottom of SidebarContent
            // via `className="mt-auto"`; the parent `<nav>` is a flex column so
            // `margin-top: auto` pushes this group to the bottom, above the footer.
            ul: secondaryNav.map((item) => renderSecondaryNavRow(item)),
            style: {
              listStyle: "none",
              margin: "0",
              padding: "0",
              display: "flex",
              flexDirection: "column",
              gap: themeSpacing(0.5),
              marginTop: "auto",
            },
          } as unknown as DomphyElement,
        ],
        style: {
          flex: "1",
          minHeight: "0",
          display: "flex",
          flexDirection: "column",
          overflowY: "auto",
          overflowX: "hidden",
          paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
        },
      } as unknown as DomphyElement,
      renderUserFooter(user),
      // Thin edge rail (upstream `SidebarRail`) — a click target to toggle the
      // sidebar. Off-screen with the panel when closed; reopen via the header.
      {
        div: null,
        ariaHidden: "true",
        onClick: () => sidebarOpen.set(!sidebarOpen.get()),
        style: {
          position: "absolute",
          insetBlock: "0",
          insetInlineEnd: "0",
          width: themeSpacing(1),
          cursor: "col-resize",
        },
      } as unknown as DomphyElement,
    ],
    dataTone: "shift-1",
    // Ctrl/Cmd+B hotkey via behavior() — a `_onMount` listener would keep
    // calling generation 1's `sidebarOpen` state after any ancestor re-render.
    ...sidebarHotkey(() => sidebarOpen.set(!sidebarOpen.get())),
    // Offcanvas panel: a fixed-position sibling of the layout spacer below.
    // Toggling `sidebarOpen` slides the whole panel off the left edge (upstream
    // `collapsible="offcanvas"` → fixed container `left: -sidebar-width`) while
    // the spacer collapses its reserved width to zero (upstream gap `w-0`).
    style: {
      position: "fixed",
      insetBlockStart: "var(--siteHeaderHeight)",
      insetBlockEnd: "0",
      insetInlineStart: "0",
      zIndex: "15",
      display: "flex",
      flexDirection: "column",
      width: themeSpacing(64),
      overflow: "hidden",
      transform: (l: Listener) =>
        sidebarOpen.get(l) ? "translateX(0)" : "translateX(-100%)",
      transition: "transform 0.2s ease",
      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)": {
        width: themeSpacing(72),
        boxShadow: (l: Listener) =>
          `0 0 ${themeSpacing(6)} ${themeColor(l, "shift-3", "neutral")}`,
      },
    },
  } as unknown as DomphyElement<"aside">;

  // Layout spacer that reserves the panel's width in the flex row on desktop and
  // collapses to zero when closed (or on mobile, where the panel is an overlay).
  const sidebarSpacer: DomphyElement<"div"> = {
    div: null,
    ariaHidden: "true",
    style: {
      flexShrink: "0",
      width: (l: Listener) => (sidebarOpen.get(l) ? themeSpacing(64) : "0"),
      transition: "width 0.2s ease",
      "@media (max-width: 768px)": { width: "0" },
    },
  } as unknown as DomphyElement<"div">;

  const mainElement: DomphyElement<"main"> = {
    main: [sidebarMainContent(children)],
    style: {
      display: "flex",
      flexDirection: "column",
      flex: "1",
      minWidth: "0",
      minHeight: "0",
      overflow: "auto",
    },
  } as unknown as DomphyElement<"main">;

  return {
    div: [
      siteHeader,
      {
        div: [
          sidebarSpacer,
          asideElement,
          mainElement,
          sidebarBackdrop(sidebarOpen, () => sidebarOpen.set(false)),
        ],
        // Layout-only row (aside + main manage their own color/background) —
        // the `var(--siteHeaderHeight)` reference in height/marginBlockStart is
        // a plain CSS custom property, not a themeColor() call, and it carries
        // no `-N` tone-step suffix, so missing-color does not fire here.
        style: {
          display: "flex",
          height: "calc(100dvh - var(--siteHeaderHeight))",
          marginBlockStart: "var(--siteHeaderHeight)",
          overflow: "hidden",
        },
      } as unknown as DomphyElement,
    ],
    style: {
      "--siteHeaderHeight": SITE_HEADER_HEIGHT,
      position: "relative",
    },
  } as unknown as DomphyElement<"div">;
}

export { sidebarStickyHeader };
export type { SidebarStickyHeaderProps, SidebarStickyHeaderSecondaryItem };
export type {
  SidebarNavChild as SidebarStickyHeaderNavChild,
  SidebarNavMainItem as SidebarStickyHeaderNavMainItem,
  SidebarProject as SidebarStickyHeaderProject,
  SidebarTeam as SidebarStickyHeaderTeam,
  SidebarUser as SidebarStickyHeaderUser,
} from "./sidebar05-08-shared.js";

← Back to shadcn/ui catalog