Detent web components
By Shane Logsdon

Navigation

Sidebar

Implemented

Cursor-following hover pill & gliding active indicator

detent-sidebar

Port status
Catalog status
implemented
Origin
upstream
Phase
2
Wave
phase-2-navigation
Group
Navigation
Nebula

7.0 KB gzip for the class module

Variants

Nebula

The preview above is the expanded (default) panel; this one is icon-only (collapsed). Both have nested sections, badges and a ⌘B hint. The active entry carries aria-current="page", a parent with children carries aria-expanded, and in icon-only mode each entry keeps an aria-label plus a tooltip. Below 768px the same panel becomes a modal drawer (role="dialog", aria-modal) that Escape and the backdrop close and that can be dragged away.

Recorded states

  • hover recorded state; no setter
  • closed recorded state; no setter
  • expanded recorded state; no setter
  • collapsed recorded state; no setter
  • dragging recorded state; no setter
  • active recorded state; no setter

Usage

Import

import '/components/sidebar/define.js';

// or register it yourself
import { DetentSidebar } from '/components/sidebar/sidebar.js';

Attributes

  • active-id
  • collapsed
  • default-collapsed
  • mobile-open

Properties

  • sections
  • activeId
  • collapsed
  • defaultCollapsed
  • mobileOpen
  • openMobile
  • closeMobile
  • onActiveChange
  • onCollapsedChange
  • onMobileOpenChange

Slots

  • footer
  • header

Events

  • detent-active-change — { id: string }
  • detent-collapsed-change — { collapsed: boolean }
  • detent-mobile-open-change — { open: boolean }

Example

Runnable example: site/examples/sidebar.ts. The snippet below is generated from the catalogued attributes.

<detent-sidebar
  active-id
  collapsed
  default-collapsed
  mobile-open
></detent-sidebar>

Property-only values

const element = document.querySelector('detent-sidebar');
if (element !== null) {
  // sections: SidebarSection[]
  element.sections = [];
  // onActiveChange: (id: string) => void
  element.onActiveChange = () => {};
  // onCollapsedChange: (collapsed: boolean) => void
  element.onCollapsedChange = () => {};
  // onMobileOpenChange: (open: boolean) => void
  element.onMobileOpenChange = () => {};
}

Source

The pinned upstream source for this row:

  • src/components/ui/sidebar.tsx sha256 647633cb886b…

Native files

The native module and its artifacts:

  • src/components/sidebar/sidebar.ts
  • src/components/sidebar/styles.css
  • src/components/sidebar/define.ts
  • docs/components/sidebar.md
  • site/examples/sidebar.ts
  • tests/browser/sidebar.spec.ts
  • evidence/components/sidebar.md

API

Boolean attributes follow HTML presence semantics: present means true, absent means false, and disabled="false" still means disabled.

Attributes

Attributes
AttributePropertyTypeDefaultNotes
active-idactiveIdstringundefinedKebab-case attribute; camelCase property.
collapsedcollapsedbooleanundefinedPresence semantics: present means true, absent means false.
default-collapseddefaultCollapsedbooleanfalsePresence semantics: present means true, absent means false.
mobile-openmobileOpenbooleanundefinedPresence semantics: present means true, absent means false.

Properties

Properties
PropertyTypeDefaultNotes
sectionsSidebarSection[]undefinedProperty-only: assign from script; not accepted as an attribute.
activeIdstringundefinedProperty-only: assign from script; not accepted as an attribute.
collapsedbooleanundefinedProperty-only: assign from script; not accepted as an attribute.
defaultCollapsedbooleanfalseProperty-only: assign from script; not accepted as an attribute.
mobileOpenbooleanundefinedProperty-only: assign from script; not accepted as an attribute.
openMobile——Recorded in the catalog without a top-level type; pinned from source when the row is ported.
closeMobile——Recorded in the catalog without a top-level type; pinned from source when the row is ported.
onActiveChange(id: string) => voidundefinedProperty-only: assign from script; not accepted as an attribute.
onCollapsedChange(collapsed: boolean) => voidundefinedProperty-only: assign from script; not accepted as an attribute.
onMobileOpenChange(open: boolean) => voidundefinedProperty-only: assign from script; not accepted as an attribute.

Events

Events
EventUpstream propPayload typeNotes
detent-active-changeonActiveChange(id: string) => voidComposed, bubbles; fires only for a user action.
detent-collapsed-changeonCollapsedChange(collapsed: boolean) => voidComposed, bubbles; fires only for a user action.
detent-mobile-open-changeonMobileOpenChange(open: boolean) => voidComposed, bubbles; fires only for a user action.

Slots

Slots
SlotNotes
footerConsumer content; rendered through a slot, never re-created.
headerConsumer content; rendered through a slot, never re-created.

CSS customisation

  • React `children`/`ReactNode` props become DOM slots; caller content stays light DOM.
  • `className` prop is replaced by documented CSS parts/custom properties; no Tailwind in consumers.
  • React callback props become typed composed CustomEvents; programmatic state assignment emits none.
  • Framer Motion springs/AnimatePresence ported to CSS transitions, WAAPI or the shared rAF spring helper.
  • `lucide-react` icon components replaced with local SVG assets (ISC notice retained).
  • React state/hooks replaced by element-owned state with pre-upgrade property preservation.
  • One panel serves both layouts (static at 768px and above, off-canvas modal drawer below) instead of two React panels, so the `header`/`footer` slots exist exactly once.
  • `useSidebar` becomes a live reader `useSidebar(element)` and `SidebarTrigger` becomes a factory returning a real button.
  • `SidebarItem.icon` is a text glyph; icon-only mode uses `visibility: hidden` plus `aria-label` (upstream `opacity: 0` kept the label in the accessibility tree).
  • `openMobile()`/`closeMobile()` are added as user-intent helpers so a trigger can emit; drag uses pointer events with an -80px threshold and a `dragging` attribute (no dragElastic, no 420/36/0.8 springs).

What it does

A collapsible application sidebar with a cursor-following hover pill, a gliding active pill, nested sections, an icon-only mode with tooltips, ⌘B/Ctrl+B toggle and a modal, draggable mobile drawer.

Upstream behaviour
  • Hover pill tracks the cursor; active pill glides between items with a shared layout spring
  • Nested items with animated expand/collapse and active dot indicator
  • Icon-only collapse mode with floating tooltips, toggled via ⌘B / Ctrl+B
  • Mobile drawer under md with drag-to-dismiss and backdrop blur
  • Header and footer slots adapt to the collapsed state via useSidebar()
Differences from upstream
  • React `children`/`ReactNode` props become DOM slots; caller content stays light DOM.
  • `className` prop is replaced by documented CSS parts/custom properties; no Tailwind in consumers.
  • React callback props become typed composed CustomEvents; programmatic state assignment emits none.
  • Framer Motion springs/AnimatePresence ported to CSS transitions, WAAPI or the shared rAF spring helper.
  • `lucide-react` icon components replaced with local SVG assets (ISC notice retained).
  • React state/hooks replaced by element-owned state with pre-upgrade property preservation.
  • One panel serves both layouts (static at 768px and above, off-canvas modal drawer below) instead of two React panels, so the `header`/`footer` slots exist exactly once.
  • `useSidebar` becomes a live reader `useSidebar(element)` and `SidebarTrigger` becomes a factory returning a real button.
  • `SidebarItem.icon` is a text glyph; icon-only mode uses `visibility: hidden` plus `aria-label` (upstream `opacity: 0` kept the label in the accessibility tree).
  • `openMobile()`/`closeMobile()` are added as user-intent helpers so a trigger can emit; drag uses pointer events with an -80px threshold and a `dragging` attribute (no dragElastic, no 420/36/0.8 springs).
Previous: Shooting Stars Next: Sign Up

Docs

Components

Project