Skip to content

cv-sidebar

Persistent layout sidebar with desktop expand/collapse, mobile overlay mode, and opt-in same-page scrollspy.

Headless base: createSidebar

Usage

View source
html
<div class="sidebar-demo-showcase" data-demo="sidebar" data-live-demo-height="940">
  <!-- Persistent layout sidebar. -->
  <div class="sidebar-demo-shell">
    <cv-sidebar class="sidebar-demo-nav" aria-label="Threat model navigation" breakpoint="0px">
      <span slot="header" class="sidebar-demo-brand">
        <span class="sidebar-demo-brand-mark" aria-hidden="true"></span>
        Threat Model
      </span>
      <span slot="toggle" class="sidebar-demo-toggle-glyph" aria-hidden="true"></span>

      <cv-sidebar-item href="#assets" active>
        <span slot="prefix" class="sidebar-demo-item-glyph">A</span>
        Assets
        <cv-badge slot="suffix" variant="primary" size="small">live</cv-badge>
      </cv-sidebar-item>

      <cv-sidebar-item href="#trust-boundaries">
        <span slot="prefix" class="sidebar-demo-item-glyph">T</span>
        Trust boundaries
      </cv-sidebar-item>

      <cv-sidebar-item href="#deniable-vaults">
        <span slot="prefix" class="sidebar-demo-item-glyph">D</span>
        Deniable vaults
      </cv-sidebar-item>

      <cv-sidebar-item href="#host-state">
        <span slot="prefix" class="sidebar-demo-item-glyph">H</span>
        Host state
      </cv-sidebar-item>

      <cv-sidebar-item href="#out-of-scope" disabled>
        <span slot="prefix" class="sidebar-demo-item-glyph">L</span>
        Out of scope
      </cv-sidebar-item>

      <span slot="footer" class="sidebar-demo-status">Mode: persistent</span>
    </cv-sidebar>

    <section class="sidebar-demo-workspace" aria-label="Selected threat model section">
      <p class="sidebar-demo-kicker">selected section</p>
      <h3>Assets under pressure</h3>
      <p>
        Navigation stays persistent while the workspace explains visible assets, hidden layers, and
        operational limits.
      </p>
      <dl class="sidebar-demo-proof-list">
        <div>
          <dt>Boundary</dt>
          <dd>device state</dd>
        </div>
        <div>
          <dt>Signal</dt>
          <dd>active section</dd>
        </div>
        <div>
          <dt>Limit</dt>
          <dd>live host compromise</dd>
        </div>
      </dl>
      <div class="sidebar-demo-mechanism">
        <span class="sidebar-demo-label">mechanism</span>
        <strong
          >Navigation groups decoy and real-vault evidence without implying cryptographic separation.</strong
        >
      </div>
    </section>
  </div>

  <div class="sidebar-demo-variants">
    <!-- Collapsed desktop rail. -->
    <div class="sidebar-demo-rail">
      <cv-sidebar collapsed size="small" aria-label="Collapsed threat model navigation" breakpoint="0px">
        <span slot="header">Threat Model</span>
        <span slot="toggle" class="sidebar-demo-toggle-glyph" aria-hidden="true"></span>

        <cv-sidebar-item href="#assets" active>
          <span slot="prefix" class="sidebar-demo-item-glyph">A</span>
          Assets
        </cv-sidebar-item>
        <cv-sidebar-item href="#trust-boundaries">
          <span slot="prefix" class="sidebar-demo-item-glyph">T</span>
          Trust boundaries
        </cv-sidebar-item>
        <cv-sidebar-item href="#deniable-vaults">
          <span slot="prefix" class="sidebar-demo-item-glyph">D</span>
          Deniable vaults
        </cv-sidebar-item>
      </cv-sidebar>

      <section class="sidebar-demo-rail-copy" aria-label="Rail state summary">
        <p class="sidebar-demo-kicker">rail state</p>
        <h3>Compact review mode</h3>
        <p>Section markers stay visible while the current threat-model notes use the remaining workspace.</p>
      </section>
    </div>

    <!-- Mobile overlay preview. -->
    <div class="sidebar-demo-overlay">
      <button
        type="button"
        class="sidebar-demo-overlay-trigger"
        data-sidebar-demo-overlay-trigger
        aria-controls="sidebar-demo-mobile-nav"
        aria-expanded="true"
        aria-label="Open mobile navigation"
        hidden
      >
        <span class="sidebar-demo-toggle-glyph" aria-hidden="true"></span>
      </button>

      <cv-sidebar
        id="sidebar-demo-mobile-nav"
        mobile
        overlay-open
        aria-label="Mobile threat model navigation"
      >
        <span slot="header">Threat Model</span>
        <span slot="toggle" class="sidebar-demo-close-glyph" aria-hidden="true"></span>

        <cv-sidebar-item href="#assets" active>
          <span slot="prefix" class="sidebar-demo-item-glyph">A</span>
          Assets
        </cv-sidebar-item>
        <cv-sidebar-item href="#trust-boundaries">
          <span slot="prefix" class="sidebar-demo-item-glyph">T</span>
          Trust boundaries
        </cv-sidebar-item>
        <cv-sidebar-item href="#host-state">
          <span slot="prefix" class="sidebar-demo-item-glyph">H</span>
          Host state
        </cv-sidebar-item>
      </cv-sidebar>

      <section class="sidebar-demo-overlay-copy" aria-label="Overlay preview workspace">
        <p class="sidebar-demo-kicker">overlay preview</p>
        <h3>Touch review overlay</h3>
        <p>The workspace stays dimmed while threat sections move into a dialog-style navigation layer.</p>
      </section>
    </div>
  </div>
</div>

<script>
  ;(() => {
    const root =
      document.currentScript?.previousElementSibling ?? document.querySelector('.sidebar-demo-showcase')
    if (!(root instanceof HTMLElement)) return

    const sidebar = root.querySelector('#sidebar-demo-mobile-nav')
    const trigger = root.querySelector('[data-sidebar-demo-overlay-trigger]')
    if (!(sidebar instanceof HTMLElement) || !(trigger instanceof HTMLButtonElement)) return

    const syncTrigger = (event) => {
      const detail = event instanceof CustomEvent ? event.detail : null
      const open =
        typeof detail?.overlayOpen === 'boolean' ? detail.overlayOpen : sidebar.hasAttribute('overlay-open')
      trigger.hidden = open
      trigger.setAttribute('aria-expanded', String(open))
    }

    trigger.addEventListener('click', () => {
      sidebar.setAttribute('overlay-open', '')
      syncTrigger()
    })

    sidebar.addEventListener('cv-change', syncTrigger)
    syncTrigger()
  })()
</script>
View source
html
<cv-sidebar scrollspy scrollspy-offset-top="80" breakpoint="0px">
  <span slot="header">On this page</span>
  <cv-sidebar-item href="#assets">Assets</cv-sidebar-item>
  <cv-sidebar-item href="#trust-boundaries">Trust Boundaries</cv-sidebar-item>
  <cv-sidebar-item href="#crypto">Crypto</cv-sidebar-item>
</cv-sidebar>

Anatomy

text
<cv-sidebar>
├── <div part="overlay">
└── <aside part="panel">
    ├── <header part="header">
    │   ├── <slot name="header">
    │   └── <button part="toggle">
    │       └── <slot name="toggle">
    ├── <nav part="body">
    │   └── <slot>
    └── <footer part="footer">
        └── <slot name="footer">

Attributes

AttributeTypeDefaultDescription
expandedBooleantrueDesktop full-width mode
collapsedBooleanfalseDesktop rail mode; inverse of expanded
mobileBooleanfalseMobile/overlay mode
overlay-openBooleanfalseMobile overlay visibility
size"small" | "medium" | "large""medium"Width preset
breakpointString"768px"Auto-switch breakpoint for mobile mode
close-on-escapeBooleantrueWhether Escape closes mobile overlay
close-on-outside-pointerBooleantrueWhether outside pointer closes mobile overlay
initial-focus-idString---Initial focus target when overlay opens
aria-labelString"Sidebar navigation"Accessible label for the panel landmark/dialog
scrollspyBooleanfalseEnables same-page hash navigation tracking
scrollspy-offset-topNumber0Top offset used when resolving the active section
scrollspy-strategy"top-anchor" | "viewport-dominant""top-anchor"Strategy used to resolve the active section
scrollspy-smooth-scrollBooleantrueUses scrollIntoView({behavior: "smooth"}) for hash items

Properties

PropertyTypeDefaultDescription
scrollspyRootDocument | ShadowRoot | Element | nullnullExplicit root used for resolving section targets
activeIdstring | nullnullReadonly id of the active scrollspy target

When scrollspyRoot is null, cv-sidebar resolves section targets in the host element root (Document or parent ShadowRoot).

Slots

SlotDescription
defaultNavigation content, including cv-sidebar-item or plain hash anchors
headerHeader content
toggleCustom toggle icon/content
footerFooter content

CSS Parts

PartDescription
overlayMobile backdrop
panelSidebar panel
headerHeader area
toggleExpand/collapse or overlay toggle
bodyMain nav body
footerFooter area

CSS Custom Properties

PropertyDefault
--cv-sidebar-inline-size280px
--cv-sidebar-rail-inline-size56px
--cv-sidebar-z-index30
--cv-sidebar-backgroundvar(--cv-color-surface, #141923)
--cv-sidebar-border-colorvar(--cv-color-border, #2a3245)
--cv-sidebar-padding-blockvar(--cv-space-3, 12px)
--cv-sidebar-padding-inlinevar(--cv-space-3, 12px)
--cv-sidebar-overlay-colorvar(--cv-color-overlay)
--cv-sidebar-transition-durationvar(--cv-duration-normal, 200ms)
--cv-sidebar-transition-easingvar(--cv-easing-standard, ease)

Events

EventDetailDescription
cv-input{expanded: boolean}Desktop user-driven expand/collapse
cv-change{expanded: boolean}Desktop committed expand/collapse
cv-input{overlayOpen: boolean}Mobile user-driven overlay open/close
cv-change{overlayOpen: boolean}Mobile committed overlay open/close
cv-expand---Desktop expand lifecycle start
cv-after-expand---Desktop expand lifecycle end
cv-collapse---Desktop collapse lifecycle start
cv-after-collapse---Desktop collapse lifecycle end
cv-overlay-open---Mobile overlay open lifecycle start
cv-after-overlay-open---Mobile overlay open lifecycle end
cv-overlay-close---Mobile overlay close lifecycle start
cv-after-overlay-close---Mobile overlay close lifecycle end
cv-scrollspy-change{activeId: string | null}Fires when the active hash target changes

Scrollspy behavior

  • scrollspy only manages same-page hash items (href="#section-id").
  • Slotted cv-sidebar-item elements receive active state automatically.
  • Slotted plain anchors receive aria-current="location" automatically.
  • Same-page hash clicks are intercepted and resolved by the sidebar scrollspy controller.
  • No scroll listeners are used. Active state is derived from IntersectionObserver.
  • top-anchor keeps classic TOC semantics based on the section closest to the configured top anchor.
  • viewport-dominant resolves the active section from effective viewport dominance using visible coverage, distance to viewport center, and hysteresis.
  • In viewport-dominant, same-page hash clicks scroll the target to the viewport center instead of the top edge.
  • In viewport-dominant, same-page hash clicks do not optimistically switch activeId; the active item updates only after observer-driven recompute.

cv-sidebar-item

Lightweight sidebar navigation item that adapts to expanded and collapsed rail modes.

Anatomy

text
<cv-sidebar-item>
└── <a part="base">
    ├── <span part="prefix">
    │   └── <slot name="prefix">
    ├── <span part="label">
    │   └── <slot>
    └── <span part="suffix">
        └── <slot name="suffix">

Attributes

AttributeTypeDefaultDescription
hrefString""Item target
activeBooleanfalseCurrent/active state
disabledBooleanfalseDisables interaction

Slots

SlotDescription
defaultLabel
prefixLeading icon/content
suffixTrailing badge/indicator

CSS Parts

PartDescription
baseRoot anchor
prefixPrefix wrapper
labelLabel wrapper
suffixSuffix wrapper

CSS Custom Properties

PropertyDefault
--cv-sidebar-item-gapvar(--cv-space-2, 8px)
--cv-sidebar-item-min-block-size32px
--cv-sidebar-item-padding-blockvar(--cv-space-2, 8px)
--cv-sidebar-item-padding-inlinevar(--cv-space-3, 12px)
--cv-sidebar-item-border-radiusvar(--cv-radius-sm, 6px)
--cv-sidebar-item-colorvar(--cv-color-text-muted, #9aa6bf)
--cv-sidebar-item-color-hovervar(--cv-color-text, #e8ecf6)
--cv-sidebar-item-color-activevar(--cv-color-primary, #65d7ff)
--cv-sidebar-item-backgroundtransparent
--cv-sidebar-item-background-hovermixed surface highlight
--cv-sidebar-item-background-activetransparent
--cv-sidebar-item-indicator-width2px
--cv-sidebar-item-indicator-colorvar(--cv-color-primary, #65d7ff)

cv-sidebar propagates collapsed and mobile context to direct child cv-sidebar-item elements so labels and suffix content are visually hidden in desktop rail mode without consumer-specific wiring.

ChromVoid UIKit documentation