Domphy

notch

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

Implementation notes

Cross-group exclusivity, outside-click/Escape dismissal and per-panel positioning are hand-rolled against a single openGroupId State rather than the popover() ui patch, because popover() owns its open/close lifecycle internally and doesn't expose a way to force-close one group's panel from a sibling group's click handler. The 'shared-position sliding highlight' is approximated with a highlight bar whose transform is computed from the selected option's row index * a fixed row height (no DOM measurement/FLIP, no floating-ui). Mount entrance animation uses the motion() patch. Exact easing/timing values follow the spec's own low-confidence guidance (~150-250ms ease-out); default offset (themeSpacing(4) ~ 16px) and accent color (primary) match the spec's documented defaults.

Status: ported · Reference: Aceternity UI original

// Aceternity "Notch" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// floating, dock-like pill pinned to a viewport edge that groups several
// dropdown-style selectors ("groups") into one compact control bar — a
// "macOS dock meets segmented settings switcher".
//
// Cross-group exclusivity (only one group's panel open at a time), the
// per-option sliding highlight, and outside-click/Escape dismissal are all
// hand-rolled against a single `openGroupId` state rather than the `popover()`
// ui patch — `popover()` owns its open/close lifecycle internally (including
// floating-ui position tracking) and doesn't expose a way to force-close one
// group's panel from a sibling group's click handler, which this component
// needs. Each panel is instead anchored with plain CSS (`position: relative`
// on the wrapper, `position: absolute` on the panel) since it only ever
// needs to sit directly above/below its own trigger — no viewport flip logic
// required.

import type { DomphyElement, ElementNode, Listener, State, ValueOrState } from "@domphy/core";
import { RecordState, toState } from "@domphy/core";
import { motion } from "@domphy/ui";
import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing } from "@domphy/theme";

export type NotchIconName = "display" | "sound" | "wifi" | "battery" | "moon" | "bluetooth";

export interface NotchOption {
  id: string;
  label: string;
  icon?: NotchIconName;
}

export interface NotchGroup {
  id: string;
  label: string;
  icon?: NotchIconName;
  options: NotchOption[];
  /** Fired with the newly chosen option id whenever this group's own selection changes. */
  onChange?: (optionId: string) => void;
}

export type NotchPosition = "top" | "bottom";
export type NotchAlign = "start" | "center" | "end";

export interface NotchProps {
  /** Group definitions. Defaults to a 3-group display/sound/network demo. */
  groups?: NotchGroup[];
  /** Controlled selection map (groupId -> optionId). Pass your own `RecordState` to read/drive it externally. */
  selected?: RecordState<Record<string, string>>;
  /** Initial per-group selection for the uncontrolled case. Falls back to each group's first option. */
  defaultSelected?: Record<string, string>;
  /** Which viewport edge the bar is pinned against — also flips which way panels open. Defaults to "top". */
  position?: NotchPosition;
  /** Horizontal alignment along the pinned edge. Defaults to "center". */
  align?: NotchAlign;
  /** Distance from the pinned edge, in `themeSpacing` units. Defaults to 4 (~16px). */
  offsetUnits?: number;
  /** Accent color for the selected-option highlight. Defaults to "primary". */
  accentColor?: ThemeColor;
  /** Toggles the dotted dividers between groups. Defaults to true. */
  showDividers?: boolean;
  /** Whether picking an option closes its panel automatically. Defaults to true. */
  closeOnSelect?: boolean;
  /** Fired on any group's selection change, after that group's own `onChange`. */
  onChange?: (groupId: string, optionId: string) => void;
  /** Plays a slide+fade mount entrance animation. Defaults to true. */
  animateOnMount?: ValueOrState<boolean>;
}

const ROW_HEIGHT_UNITS = 9;
const PANEL_GAP_UNITS = 2;

const DEFAULT_GROUPS: NotchGroup[] = [
  {
    id: "display",
    label: "Display",
    icon: "display",
    options: [
      { id: "auto", label: "Auto" },
      { id: "light", label: "Light" },
      { id: "dark", label: "Dark" },
    ],
  },
  {
    id: "sound",
    label: "Sound",
    icon: "sound",
    options: [
      { id: "on", label: "On" },
      { id: "muted", label: "Muted" },
    ],
  },
  {
    id: "network",
    label: "Network",
    icon: "wifi",
    options: [
      { id: "home", label: "Home Wi-Fi" },
      { id: "office", label: "Office Wi-Fi" },
      { id: "off", label: "Off" },
    ],
  },
];

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

const ICON_SHAPES: Record<NotchIconName, DomphyElement[]> = {
  display: [
    { rect: null, x: "3", y: "4", width: "18", height: "12", rx: "2" },
    { line: null, x1: "8", y1: "20", x2: "16", y2: "20" },
    { line: null, x1: "12", y1: "16", x2: "12", y2: "20" },
  ],
  sound: [
    { path: null, d: "M4 9v6h4l5 4V5L8 9H4z" },
    { path: null, d: "M17 8c1.5 1.5 1.5 6.5 0 8" },
  ],
  wifi: [
    { path: null, d: "M2 9c6-5 14-5 20 0" },
    { path: null, d: "M5.5 13c4-3.5 9-3.5 13 0" },
    { path: null, d: "M9 17c2-1.5 4-1.5 6 0" },
    { circle: null, cx: "12", cy: "20", r: "0.8", fill: "currentColor" },
  ],
  battery: [
    { rect: null, x: "3", y: "7", width: "16", height: "10", rx: "2" },
    { line: null, x1: "21", y1: "10", x2: "21", y2: "14" },
    { rect: null, x: "5", y: "9", width: "9", height: "6", fill: "currentColor" },
  ],
  moon: [{ path: null, d: "M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z" }],
  bluetooth: [{ polyline: null, points: "7,7 17,17 12,21 12,3 17,7 7,17" }],
};

function notchGlyph(name: NotchIconName): DomphyElement<"svg"> {
  return {
    svg: ICON_SHAPES[name],
    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 notchIconBox(name: NotchIconName): DomphyElement<"span"> {
  return {
    span: [notchGlyph(name)],
    style: {
      display: "inline-flex",
      flexShrink: "0",
      width: themeSpacing(4),
      height: themeSpacing(4),
    },
  };
}

/** Hairline dotted vertical divider between adjacent groups. */
function notchDivider(index: number): DomphyElement<"div"> {
  // `_doctorDisable` is a doctor-only annotation not present in core's strict
  // `PartialElement` type — build through an untyped literal, then assert, so
  // the excess-property check doesn't fire (mirrors the shadcn sidebar
  // family's `verticalDivider()`). Purely decorative — no text of its own.
  const element = {
    div: null,
    ariaHidden: "true",
    _key: `divider-${index}`,
    _doctorDisable: "missing-color",
    style: {
      alignSelf: "stretch",
      borderInlineStart: (listener: Listener) => `1px dotted ${themeColor(listener, "shift-5")}`,
    },
  };
  return element as DomphyElement<"div">;
}

/** Sliding accent bar that tracks the currently selected option's row index. */
function notchHighlightBar(
  group: NotchGroup,
  selection: RecordState<Record<string, string>>,
  accentColor: ThemeColor,
): DomphyElement<"div"> {
  const element = {
    div: null,
    ariaHidden: "true",
    _key: "highlight",
    // Edge-anchor the bar's own tiny surface (dataTone-surface-contract) so
    // its background can stay "inherit" instead of a fixed shifted tone
    // (tone-background-inherit) while still reading as an accent fill.
    dataTone: "shift-3",
    style: {
      position: "absolute",
      insetInlineStart: themeSpacing(1),
      insetInlineEnd: themeSpacing(1),
      zIndex: 0,
      height: themeSpacing(ROW_HEIGHT_UNITS),
      borderRadius: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
      transform: (listener: Listener) => {
        const optionIndex = group.options.findIndex(
          (option) => option.id === selection.get(group.id, listener),
        );
        return `translateY(calc(${Math.max(optionIndex, 0)} * ${themeSpacing(ROW_HEIGHT_UNITS)}))`;
      },
      transition: "transform 200ms cubic-bezier(0.22, 1, 0.36, 1)",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit", accentColor),
      color: (listener: Listener) => themeColor(listener, "shift-9", accentColor),
    },
  };
  return element as DomphyElement<"div">;
}

function notchOptionRow(
  group: NotchGroup,
  option: NotchOption,
  selection: RecordState<Record<string, string>>,
  openGroupId: State<string | null>,
  closeOnSelect: boolean,
  onChange?: (groupId: string, optionId: string) => void,
): DomphyElement<"li"> {
  const isSelected = (listener: Listener) => selection.get(group.id, listener) === option.id;

  const rowChildren: DomphyElement[] = [];
  if (option.icon) rowChildren.push(notchIconBox(option.icon));
  rowChildren.push({ span: option.label } as DomphyElement<"span">);

  return {
    li: [
      {
        button: rowChildren,
        role: "option",
        ariaSelected: (listener: Listener) => isSelected(listener) || undefined,
        onClick: () => {
          selection.set(group.id, option.id);
          group.onChange?.(option.id);
          onChange?.(group.id, option.id);
          if (closeOnSelect) openGroupId.set(null);
        },
        style: {
          position: "relative",
          zIndex: 1,
          display: "flex",
          alignItems: "center",
          width: "100%",
          border: "none",
          background: "none",
          appearance: "none",
          cursor: "pointer",
          textAlign: "left",
          height: themeSpacing(ROW_HEIGHT_UNITS),
          gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
          paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
          borderRadius: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
          fontSize: (listener: Listener) => themeSize(listener, "decrease-1"),
          color: (listener: Listener) => themeColor(listener, isSelected(listener) ? "shift-12" : "shift-9"),
          transition: "color 150ms ease",
        },
      } as DomphyElement<"button">,
    ],
    _key: option.id,
  };
}

function notchGroupTrigger(
  group: NotchGroup,
  selection: RecordState<Record<string, string>>,
  openGroupId: State<string | null>,
): DomphyElement<"button"> {
  const triggerChildren: DomphyElement[] = [];
  if (group.icon) triggerChildren.push(notchIconBox(group.icon));
  triggerChildren.push({
    span: (listener: Listener) => {
      const selectedId = selection.get(group.id, listener);
      const selectedOption = group.options.find((option) => option.id === selectedId);
      return selectedOption?.label ?? group.label;
    },
  } as DomphyElement<"span">);

  return {
    button: triggerChildren,
    ariaHaspopup: "listbox",
    ariaLabel: group.label,
    ariaExpanded: (listener: Listener) => openGroupId.get(listener) === group.id,
    onClick: () => {
      openGroupId.set(openGroupId.get() === group.id ? null : group.id);
    },
    style: {
      display: "flex",
      alignItems: "center",
      border: "none",
      background: "none",
      appearance: "none",
      cursor: "pointer",
      whiteSpace: "nowrap",
      borderRadius: themeSpacing(999),
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 1.5),
      paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 1.5),
      paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
      fontSize: (listener: Listener) => themeSize(listener, "decrease-1"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      transition: "background-color 150ms ease",
      "&:hover": { backgroundColor: (listener: Listener) => themeColor(listener, "increase-1") },
      "&[aria-expanded=true]": { backgroundColor: (listener: Listener) => themeColor(listener, "increase-1") },
    },
  };
}

function notchGroupPanel(
  group: NotchGroup,
  selection: RecordState<Record<string, string>>,
  openGroupId: State<string | null>,
  accentColor: ThemeColor,
  closeOnSelect: boolean,
  position: NotchPosition,
  onChange?: (groupId: string, optionId: string) => void,
): DomphyElement<"div"> {
  const isOpen = (listener: Listener) => openGroupId.get(listener) === group.id;
  const listChildren: DomphyElement[] = [notchHighlightBar(group, selection, accentColor)];
  for (const option of group.options) {
    listChildren.push(notchOptionRow(group, option, selection, openGroupId, closeOnSelect, onChange));
  }

  // Closed-state resting offset (scale-down + a small nudge back toward the
  // pinned edge it grew from), computed once since `position` is static.
  const closedTranslate =
    position === "top" ? `calc(-1 * ${themeSpacing(1)})` : themeSpacing(1);

  return {
    div: [
      {
        ul: listChildren,
        role: "listbox",
        ariaLabel: `${group.label} options`,
        style: {
          position: "relative",
          listStyle: "none",
          margin: "0",
          padding: "0",
          display: "flex",
          flexDirection: "column",
        },
      } as DomphyElement<"ul">,
    ],
    role: "presentation",
    _key: `panel-${group.id}`,
    dataTone: "shift-16",
    style: {
      position: "absolute",
      insetInlineStart: "0",
      top: position === "top" ? `calc(100% + ${themeSpacing(PANEL_GAP_UNITS)})` : undefined,
      bottom: position === "bottom" ? `calc(100% + ${themeSpacing(PANEL_GAP_UNITS)})` : undefined,
      zIndex: 30,
      minWidth: themeSpacing(44),
      overflow: "hidden",
      transformOrigin: position === "top" ? "top center" : "bottom center",
      borderRadius: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
      padding: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3")}`,
      outlineOffset: "-1px",
      boxShadow: (listener: Listener) =>
        `0 ${themeSpacing(3)} ${themeSpacing(12)} ${themeColor(listener, "shift-4")}`,
      opacity: (listener: Listener) => (isOpen(listener) ? 1 : 0),
      visibility: (listener: Listener) => (isOpen(listener) ? "visible" : "hidden"),
      pointerEvents: (listener: Listener) => (isOpen(listener) ? "auto" : "none"),
      transform: (listener: Listener) =>
        isOpen(listener) ? "scale(1) translateY(0)" : `scale(0.95) translateY(${closedTranslate})`,
      transition: "opacity 150ms ease, transform 150ms ease, visibility 150ms",
    },
  };
}

function notchGroupWrapper(
  group: NotchGroup,
  selection: RecordState<Record<string, string>>,
  openGroupId: State<string | null>,
  accentColor: ThemeColor,
  closeOnSelect: boolean,
  position: NotchPosition,
  onChange?: (groupId: string, optionId: string) => void,
): DomphyElement<"div"> {
  return {
    div: [
      notchGroupTrigger(group, selection, openGroupId),
      notchGroupPanel(group, selection, openGroupId, accentColor, closeOnSelect, position, onChange),
    ],
    _key: group.id,
    style: { position: "relative", display: "flex" },
  };
}

/**
 * A floating dock-like control pill pinned to a viewport edge, grouping
 * several dropdown selectors ("groups") behind compact icon+label triggers.
 * Only one group's panel is open at a time; picking an option slides an
 * accent highlight to the new row and (by default) closes the panel. Call
 * with no arguments for a working display/sound/network demo.
 */
function notch(props: NotchProps = {}): DomphyElement<"nav"> {
  const groups = props.groups ?? DEFAULT_GROUPS;
  const position = props.position ?? "top";
  const align = props.align ?? "center";
  const offsetUnits = props.offsetUnits ?? 4;
  const accentColor = props.accentColor ?? "primary";
  const showDividers = props.showDividers ?? true;
  const closeOnSelect = props.closeOnSelect ?? true;
  const animateOnMount = toState(props.animateOnMount ?? true);

  const initialSelection: Record<string, string> = {};
  for (const group of groups) {
    initialSelection[group.id] = props.defaultSelected?.[group.id] ?? group.options[0]?.id ?? "";
  }
  const selection = props.selected ?? new RecordState<Record<string, string>>(initialSelection);
  const openGroupId = toState<string | null>(null);

  const children: DomphyElement[] = [];
  groups.forEach((group, index) => {
    if (showDividers && index > 0) children.push(notchDivider(index));
    children.push(
      notchGroupWrapper(group, selection, openGroupId, accentColor, closeOnSelect, position, props.onChange),
    );
  });

  const alignStyle: Record<string, string> =
    align === "start"
      ? { insetInlineStart: themeSpacing(offsetUnits) }
      : align === "end"
        ? { insetInlineEnd: themeSpacing(offsetUnits) }
        : { insetInlineStart: "0", insetInlineEnd: "0", marginInline: "auto" };

  const element: DomphyElement<"nav"> = {
    nav: children,
    ariaLabel: "Quick settings",
    dataTone: "shift-14",
    $: animateOnMount.get()
      ? [
          motion({
            initial: { opacity: 0, y: position === "top" ? -16 : 16, scale: 0.95 },
            animate: { opacity: 1, y: 0, scale: 1 },
            transition: { duration: 220, easing: "cubic-bezier(0.16, 1, 0.3, 1)" },
          }),
        ]
      : [],
    style: {
      position: "fixed",
      [position]: themeSpacing(offsetUnits),
      ...alignStyle,
      zIndex: 40,
      width: "fit-content",
      display: "flex",
      alignItems: "center",
      borderRadius: themeSpacing(999),
      gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
      paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 1.5),
      paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3")}`,
      outlineOffset: "-1px",
      boxShadow: (listener: Listener) =>
        `0 ${themeSpacing(2)} ${themeSpacing(10)} ${themeColor(listener, "shift-4")}`,
      backdropFilter: (listener: Listener) => `blur(${themeSpacing(4)})`,
    },
    _onMount: (node: ElementNode) => {
      const element_ = node.domElement as HTMLElement | null;
      if (!element_) return;

      const handleOutsideClick = (event: MouseEvent) => {
        if (!element_.contains(event.target as Node)) openGroupId.set(null);
      };
      const handleKeydown = (event: KeyboardEvent) => {
        if (event.key === "Escape") openGroupId.set(null);
      };

      document.addEventListener("click", handleOutsideClick);
      document.addEventListener("keydown", handleKeydown);
      node.addHook("Remove", () => {
        document.removeEventListener("click", handleOutsideClick);
        document.removeEventListener("keydown", handleKeydown);
      });
    },
  };

  return element;
}

export { notch };

← Back to Aceternity UI catalog