Domphy

hoverSidebar

A Navigation block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call hoverSidebar() with no arguments for a working demo, or edit the code below live.

Implementation notes

Domphy has no context/provider primitive; the spec's shared open/expanded context is substituted with a plain State<boolean> threaded as a function argument between the desktop-rail and mobile-drawer builders (functionally equivalent for read/write sharing across both variants, documented in the file header). Desktop hover-expand uses mouseenter/mouseleave on the aside; mobile uses the drawer() ui patch as an overlay with a toggle button below a configurable CSS media-feature breakpoint (default ~47.9em, matching the shadcn-sidebar family's own precedent in this repo). Labels are functionally hidden at icon-only width via aria-hidden on the visible label span, with the accessible name carried permanently on the anchor's aria-label instead — so accessibility never depends on the collapse state. Collapsed/expanded widths (~15/56 spacing units) are a reasonable low-confidence default per the spec's own note that exact pixel widths aren't documented.

Status: ported · Reference: Aceternity UI original

// Aceternity "Sidebar" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A dark,
// icon-only vertical rail fixed to the left edge that expands to show labels
// on hover, plus a bottom profile row. Below `mobileBreakpoint` the rail is
// replaced by a toggled overlay drawer.
//
// The upstream component shares open/expanded state via a React context so
// nested link/body pieces can read it without prop drilling. Domphy has no
// context primitive, but a `State<boolean>` threaded as a plain argument
// serves the identical purpose (any function holding a reference can read
// or write it) — the desktop rail and the mobile drawer below both close
// over the same `expandedState`/`mobileOpen` instances.

import type { DomphyElement, Listener, State, StyleObject, ValueOrState } from "@domphy/core";
import { toState } from "@domphy/core";
import { avatar, buttonGhost, drawer } from "@domphy/ui";
import { themeColor, themeDensity, themeSpacing } from "@domphy/theme";

export type HoverSidebarIconName = "home" | "dashboard" | "projects" | "messages" | "settings";

export interface HoverSidebarLink {
  label: string;
  icon: HoverSidebarIconName;
  href?: string;
  target?: string;
}

export interface HoverSidebarProfile {
  name: string;
  /** Image URL. When omitted, the avatar falls back to initials. */
  avatarSrc?: string;
}

export interface HoverSidebarProps {
  /** Nav link entries. Defaults to a 5-item demo set. */
  links?: HoverSidebarLink[];
  /** Expanded/open state, shared by the desktop rail and the mobile drawer. Pass your own `State` to control it externally. Defaults to false. */
  expanded?: ValueOrState<boolean>;
  /** Eases the width/label transition on hover when true; instant when false. Defaults to true. */
  animate?: boolean;
  /** Bottom profile row content. Defaults to a placeholder user. */
  profile?: HoverSidebarProfile;
  /** CSS media feature below which the hover-rail becomes a toggled overlay drawer. Defaults to "(max-width: 47.9375em)". */
  mobileBreakpoint?: string;
}

const COLLAPSED_WIDTH = themeSpacing(15);
const EXPANDED_WIDTH = themeSpacing(56);
const MOBILE_BREAKPOINT = "(max-width: 47.9375em)";

const DEFAULT_LINKS: HoverSidebarLink[] = [
  { label: "Home", icon: "home", href: "#" },
  { label: "Dashboard", icon: "dashboard", href: "#" },
  { label: "Projects", icon: "projects", href: "#" },
  { label: "Messages", icon: "messages", href: "#" },
  { label: "Settings", icon: "settings", href: "#" },
];

const DEFAULT_PROFILE: HoverSidebarProfile = { name: "Alex Rivera" };

// ---------------------------------------------------------------------------
// Hand-authored generic line icons (24x24, stroke=currentColor) — simple
// geometric silhouettes, not sourced from or tracing any icon library.
// ---------------------------------------------------------------------------

const ICON_SHAPES: Record<HoverSidebarIconName, DomphyElement[]> = {
  home: [
    { polyline: null, points: "4,12 12,5 20,12" },
    { rect: null, x: "6", y: "12", width: "12", height: "8" },
  ],
  dashboard: [
    { rect: null, x: "3", y: "3", width: "8", height: "8", rx: "1" },
    { rect: null, x: "13", y: "3", width: "8", height: "8", rx: "1" },
    { rect: null, x: "3", y: "13", width: "8", height: "8", rx: "1" },
    { rect: null, x: "13", y: "13", width: "8", height: "8", rx: "1" },
  ],
  projects: [{ path: null, d: "M4 7h6l2 3h8v9H4z" }],
  messages: [
    {
      path: null,
      d: "M4 5h16a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H9l-4 3v-3H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z",
    },
  ],
  settings: [
    { circle: null, cx: "12", cy: "12", r: "3" },
    { circle: null, cx: "12", cy: "12", r: "8" },
  ],
};

const MENU_SHAPE: DomphyElement[] = [
  { line: null, x1: "4", y1: "7", x2: "20", y2: "7" },
  { line: null, x1: "4", y1: "12", x2: "20", y2: "12" },
  { line: null, x1: "4", y1: "17", x2: "20", y2: "17" },
];

const CLOSE_SHAPE: DomphyElement[] = [
  { line: null, x1: "5", y1: "5", x2: "19", y2: "19" },
  { line: null, x1: "19", y1: "5", x2: "5", y2: "19" },
];

function rawGlyph(shape: DomphyElement[]): DomphyElement<"svg"> {
  return {
    svg: shape,
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: "1.75",
    strokeLinecap: "round",
    strokeLinejoin: "round",
    role: "img",
    ariaHidden: "true",
    style: { width: "100%", height: "100%" },
  } as DomphyElement<"svg">;
}

function sidebarGlyph(name: HoverSidebarIconName): DomphyElement<"svg"> {
  return rawGlyph(ICON_SHAPES[name]);
}

function iconBox(glyph: DomphyElement<"svg">): DomphyElement<"span"> {
  return {
    span: [glyph],
    style: {
      display: "inline-flex",
      flexShrink: "0",
      width: themeSpacing(5),
      height: themeSpacing(5),
    },
  };
}

/** Label span that fades/collapses away in icon-rail mode. Visual only —
 * the anchor itself always carries `ariaLabel`, so the link's accessible
 * name never depends on the collapse state. */
function collapsibleLabel(expanded: State<boolean>, animate: boolean, text: string): DomphyElement<"span"> {
  return {
    span: text,
    ariaHidden: "true",
    style: {
      display: "inline-flex",
      overflow: "hidden",
      whiteSpace: "nowrap",
      opacity: (listener: Listener) => (expanded.get(listener) ? 1 : 0),
      maxWidth: (listener: Listener) => (expanded.get(listener) ? themeSpacing(40) : "0em"),
      transition: animate ? "opacity 200ms ease, max-width 200ms ease" : "none",
    },
  };
}

function sidebarLinkRow(
  link: HoverSidebarLink,
  expanded: State<boolean>,
  animate: boolean,
): DomphyElement<"li"> {
  return {
    li: [
      {
        a: [iconBox(sidebarGlyph(link.icon)), collapsibleLabel(expanded, animate, link.label)],
        href: link.href ?? "#",
        target: link.target,
        ariaLabel: link.label,
        style: {
          display: "flex",
          alignItems: "center",
          textDecoration: () => "none",
          gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
          paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
          paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
          borderRadius: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
          color: (listener: Listener) => themeColor(listener, "shift-8"),
          "&:hover": {
            backgroundColor: (listener: Listener) => themeColor(listener, "increase-1"),
            color: (listener: Listener) => themeColor(listener, "shift-10"),
          },
        },
      } as DomphyElement<"a">,
    ],
    _key: link.label,
  };
}

function sidebarProfileRow(
  profile: HoverSidebarProfile,
  expanded: State<boolean>,
  animate: boolean,
): DomphyElement<"div"> {
  const avatarContent: DomphyElement[] | string = profile.avatarSrc
    ? [{ img: null, src: profile.avatarSrc, alt: "" } as DomphyElement<"img">]
    : profile.name.slice(0, 2).toUpperCase();

  return {
    div: [
      { span: avatarContent, $: [avatar({ color: "primary" })] } as DomphyElement<"span">,
      {
        span: profile.name,
        style: {
          display: "inline-flex",
          overflow: "hidden",
          whiteSpace: "nowrap",
          opacity: (listener: Listener) => (expanded.get(listener) ? 1 : 0),
          maxWidth: (listener: Listener) => (expanded.get(listener) ? themeSpacing(40) : "0em"),
          transition: animate ? "opacity 200ms ease, max-width 200ms ease" : "none",
          color: (listener: Listener) => themeColor(listener, "shift-9"),
        },
      } as DomphyElement<"span">,
    ],
    style: {
      display: "flex",
      alignItems: "center",
      marginTop: "auto",
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
      paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
      paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
      borderTop: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3")}`,
      color: (listener: Listener) => themeColor(listener, "shift-9"),
    },
  };
}

function linkListStyle(): Record<string, unknown> {
  return { listStyle: "none", margin: "0", padding: "0", display: "flex", flexDirection: "column" };
}

function hoverSidebarDesktopAside(
  links: HoverSidebarLink[],
  expandedState: State<boolean>,
  animate: boolean,
  profile: HoverSidebarProfile,
  mobileBreakpoint: string,
): DomphyElement<"aside"> {
  // `mobileBreakpoint` is a plain `string` prop (not a literal), so a
  // computed `@media ${mobileBreakpoint}` key inline in the object literal
  // below can't be *inferred* as matching `StyleObject`'s narrower
  // `` `@${string}` `` index signature — TypeScript always widens a
  // non-literal computed key to a generic `[x: string]` on the literal's
  // inferred type, which then fails structural comparison against the
  // pattern-specific target type. Building the static part as its own
  // literal (clean inference) and adding the dynamic media-query entry via
  // a separately-cast assignment sidesteps that inference limitation.
  const asideStyle: StyleObject = {
    position: "fixed",
    insetBlock: "0",
    insetInlineStart: "0",
    zIndex: 30,
    display: "flex",
    flexDirection: "column",
    overflow: "hidden",
    width: (listener: Listener) => (expandedState.get(listener) ? EXPANDED_WIDTH : COLLAPSED_WIDTH),
    paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
    transition: animate ? "width 250ms ease" : "none",
    backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
    color: (listener: Listener) => themeColor(listener, "shift-9"),
    borderInlineEnd: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3")}`,
  };
  (asideStyle as Record<string, StyleObject>)[`@media ${mobileBreakpoint}`] = { display: "none" };

  return {
    aside: [
      {
        ul: links.map((link) => sidebarLinkRow(link, expandedState, animate)),
        style: { ...linkListStyle(), gap: themeSpacing(1) },
      } as DomphyElement<"ul">,
      sidebarProfileRow(profile, expandedState, animate),
    ],
    ariaLabel: "Primary navigation",
    dataTone: "shift-16",
    onMouseEnter: () => expandedState.set(true),
    onMouseLeave: () => expandedState.set(false),
    style: asideStyle,
  };
}

function hoverSidebarMobileToggle(
  mobileOpen: State<boolean>,
  mobileBreakpoint: string,
): DomphyElement<"button"> {
  // See the matching comment in `hoverSidebarDesktopAside`.
  const toggleStyle: StyleObject = {
    display: "none",
    position: "fixed",
    top: themeSpacing(3),
    insetInlineStart: themeSpacing(3),
    zIndex: 35,
  };
  (toggleStyle as Record<string, StyleObject>)[`@media ${mobileBreakpoint}`] = { display: "inline-flex" };

  return {
    button: [iconBox(rawGlyph(MENU_SHAPE))],
    ariaLabel: "Open navigation menu",
    onClick: () => mobileOpen.set(true),
    $: [buttonGhost()],
    style: toggleStyle,
  };
}

function hoverSidebarMobileDrawer(
  links: HoverSidebarLink[],
  mobileOpen: State<boolean>,
  profile: HoverSidebarProfile,
): DomphyElement<"dialog"> {
  // The mobile drawer always renders fully expanded — there is no icon-rail
  // mode once the sidebar has already collapsed into an overlay sheet.
  const alwaysExpanded = toState(true);

  return {
    dialog: [
      {
        button: [iconBox(rawGlyph(CLOSE_SHAPE))],
        ariaLabel: "Close navigation menu",
        onClick: () => mobileOpen.set(false),
        $: [buttonGhost()],
        style: { alignSelf: "flex-end" },
      } as DomphyElement<"button">,
      {
        ul: links.map((link) => sidebarLinkRow(link, alwaysExpanded, false)),
        style: { ...linkListStyle(), gap: themeSpacing(1) },
      } as DomphyElement<"ul">,
      sidebarProfileRow(profile, alwaysExpanded, false),
    ],
    dataTone: "shift-16",
    $: [drawer({ open: mobileOpen, placement: "start", size: EXPANDED_WIDTH })],
    style: {
      display: "flex",
      flexDirection: "column",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
    },
  };
}

/**
 * A dark, icon-only vertical navigation rail fixed to the left edge that
 * expands on hover (desktop) or becomes a toggled overlay drawer (mobile).
 * Call with no arguments for a working 5-link demo with a profile footer.
 */
function hoverSidebar(props: HoverSidebarProps = {}): DomphyElement<"div"> {
  const links = props.links ?? DEFAULT_LINKS;
  const animate = props.animate ?? true;
  const profile = props.profile ?? DEFAULT_PROFILE;
  const mobileBreakpoint = props.mobileBreakpoint ?? MOBILE_BREAKPOINT;
  const expandedState = toState(props.expanded ?? false);
  const mobileOpen = toState(false);

  return {
    div: [
      hoverSidebarDesktopAside(links, expandedState, animate, profile, mobileBreakpoint),
      hoverSidebarMobileToggle(mobileOpen, mobileBreakpoint),
      hoverSidebarMobileDrawer(links, mobileOpen, profile),
    ],
    style: { display: "contents" },
  };
}

export { hoverSidebar };

← Back to Aceternity UI catalog