npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@diabolic/hangover

v0.5.4

Published

A headless-style, compound React dropdown/field-picker component library

Readme

hangover

A React 18 compound-component Dropdown / Field Picker library.

npm version License: MIT

Live Demo

Features

  • Compound Components - Composable Dropdown.Trigger, Panel, Navigation, Section, Group, Item API
  • Fuzzy Search - Built-in fuzzy filtering across items (powered by fuse.js)
  • Keyboard Navigation - Arrow up/down (with wrap) to move between items, left/right to jump between the nav column and content, Enter to trigger
  • Two Display Modes - Scroll-spy with smooth scroll or one-section-at-a-time tab mode
  • Left Navigation - Optional nav column with auto-collapse and single-section auto-transform
  • Checkbox Items - Multi-select with select-all support
  • Dark Mode - Token-based theming with a built-in dark theme
  • Smart Positioning - Portal-rendered panel with placement variants and auto-placement
  • Controlled & Uncontrolled - Full control or sensible defaults out of the box
  • Imperative API - Open, close, and drive state via ref with an optional external anchor
  • Config-driven Rendering - Build entire menus from a single fromConfig object
  • React 18 - Compound, accessible, headless-style components

Installation

npm install @diabolic/hangover

Quick Start

import { Dropdown } from '@diabolic/hangover'

export default function App() {
  return (
    <Dropdown>
      <Dropdown.Trigger>
        <button>Open</button>
      </Dropdown.Trigger>
      <Dropdown.Panel>
        <Dropdown.Content>
          <Dropdown.Section>
            <Dropdown.Group label="Fruits">
              <Dropdown.Item id="apple">Apple</Dropdown.Item>
              <Dropdown.Item id="banana">Banana</Dropdown.Item>
            </Dropdown.Group>
          </Dropdown.Section>
        </Dropdown.Content>
      </Dropdown.Panel>
    </Dropdown>
  )
}

Styles are imported automatically with the component. Do not import from dist/... directly.


Component Reference

<Dropdown>

Root provider. All state lives here.

| Prop | Type | Default | Description | |---|---|---|---| | displayMode | "scroll" \| "tab" | "scroll" | How sections are navigated. "scroll" uses scroll-spy + smooth scroll; "tab" shows one section at a time. | | defaultOpen | boolean | false | Panel starts open. | | defaultGroupExpanded | "first" \| true \| false | true | Default expand state for all groups. true expands all; false collapses all; "first" expands only the first group across all sections. | | hideOnSelection | boolean | true | Close the panel automatically when a type="click" item is selected. Set to false to keep the panel open. | | darkMode | boolean | false | Enable dark mode. Applies hangoverDropdown--dark CSS class which overrides all color tokens. | | searchQuery | string | — | Controlled search query. When provided, the internal search state is kept in sync with this value. Use together with onEvent (type: "search") to handle changes. | | defaultSearchQuery | string | "" | Uncontrolled initial search query. Only applied on first render. | | useTranslationFunction | (text, payload?) => string | — | Translation hook. When provided, every user-facing string — including built-in defaults — is routed through this function. See Translation. | | groupHeaderStyle | "accent" \| "light" | "accent" | Group header appearance. "accent" is the filled header with a colored accent bar; "light" renders headers like section titles (flat, uppercase, muted, no accent) — handy when there is no navigation column. | | autoFocusSearch | boolean | true | Focus the search input automatically when the panel opens. Set to false to leave focus untouched. | | hideEmptyResults | boolean | true | While searching, completely remove groups that have no matching items (instead of showing a "No results" placeholder), hide the titles of sections with no matches, and disable those categories in the navigation column. Set to false to keep every group/section rendered and show the per-group "No results" text instead. | | onEvent | (event) => any | — | Central event handler. See Events. | | ref | React.Ref | — | Exposes imperative API. See Imperative API. | | ...rest | any | — | Any additional props (e.g. data-*, className, style) are forwarded to the root <div>. |


<Dropdown.Trigger>

Wraps any single child element and turns it into the toggle trigger.

Injects ref, onClick, aria-expanded, aria-haspopup onto the child automatically — no extra props needed.

<Dropdown.Trigger>
  <button>Open</button>
</Dropdown.Trigger>

Dropdown.Trigger is optional. When you cannot add markup inside <Dropdown>, use an external anchor ref with the imperative API instead. See Without a Trigger.


<Dropdown.Panel>

Renders into a portal on document.body. Handles positioning, outside-click, and Escape key closing.

| Prop | Type | Default | Description | |---|---|---|---| | placement | string | "bottom-start" | Panel position relative to the trigger. Supported: "bottom-start", "bottom-end", "bottom", "top-start", "top-end", "top". | | title | string | — | Optional title bar rendered at the top of the panel (above the nav/content area). Uses the same muted uppercase style as section headings. | | offset | number \| string | 8 | Distance between trigger and panel. Accepts a number (10) or a px string ("10px"). | | anchor | React.RefObject | — | Ref to an external DOM element used as the positioning anchor. Overrides the built-in trigger ref. Use together with the imperative API when Dropdown.Trigger is not in the markup. | | avoid | string \| Element \| Ref \| Array<string \| Element \| Ref> | — | Elements the panel should not overlap. Accepts CSS selector strings, DOM elements, or refs (or an array of them). While auto-placing, the direction with the least overlap against these nodes is preferred. | | placementPriority | string[] | — | Order in which sides/placements are tried when auto-placing, e.g. ['bottom', 'top', 'right', 'left']. The first side that fits and is clear (of the viewport edges and any avoid nodes) is used. Bare sides inherit the alignment from placement. | | beforePlace | (panelEl: HTMLElement) => Promise<void> | — | Async hook called once, after the panel mounts but before the initial placement is calculated. The positioner waits for the returned promise to settle before measuring and placing — useful when panel content loads asynchronously or its height changes from an external source. Errors are swallowed and placement still proceeds. Subsequent scroll/resize/drag recalculations skip this hook. | | component | React component | — | Custom wrapper component. | | ...rest | any | — | Any additional props are forwarded to the panel <div> (or component). |


<Dropdown.Navigation>

Left navigation column. When present, the panel switches to a two-column layout.

| Prop | Type | Default | Description | |---|---|---|---| | showAll | boolean | false | Automatically prepends an All nav item with id "__all__". | | allLabel | string | "All" | Label for the auto-prepended All item. | | allIcon | ReactNode \| FC | — | Icon for the auto-prepended All item. | | collapsed | boolean | false | Start the nav column in collapsed state. | | autoCollapse | boolean \| "auto" | false | Automatically collapse the nav column when the viewport is too narrow to fit the full panel width (derived from --hangover-nav-width + --hangover-content-max-width). Use "auto" with collapsed to start collapsed and expand while the nav is hovered or keyboard-focused. | | component | React component | — | Custom wrapper component. | | ...rest | any | — | Any additional props are forwarded to the nav column wrapper <div> (or component). |

Single-section auto-transform — When <Dropdown.Navigation> has exactly one child item (excluding the auto-prepended All item), the nav column is hidden automatically and section titles are suppressed. No extra prop is needed; it is detected at render time.

{/* Nav column and section title disappear automatically */}
<Dropdown.Navigation showAll>
  <Dropdown.NavigationItem id="metrics">Metrics</Dropdown.NavigationItem>
</Dropdown.Navigation>

Renders Dropdown.NavigationItem children inside a forNavigation column.

Use showAll to automatically prepend an All item:

<Dropdown.Navigation showAll>
  <Dropdown.NavigationItem id="fruits" icon={<IconFruits />}>Fruits</Dropdown.NavigationItem>
</Dropdown.Navigation>

Or provide a custom label/icon for the All item:

<Dropdown.Navigation showAll allLabel="Everything" allIcon={IconAll}>
  <Dropdown.NavigationItem id="fruits">Fruits</Dropdown.NavigationItem>
</Dropdown.Navigation>

<Dropdown.NavigationItem>

A single item in the navigation column.

| Prop | Type | Default | Description | |---|---|---|---| | id | string | required | Must match the forId of a Dropdown.Section. | | icon | ReactNode \| FC | — | Icon displayed left of the label. | | component | React component | — | Custom component. Receives isActive, onClick, id. | | ...rest | any | — | Any additional props are forwarded to the <button> (or component). onClick is composed with the internal nav handler. |

In displayMode="scroll", clicking a nav item smooth-scrolls to the matching section. The active item is updated automatically as the user scrolls (scroll spy).


<Dropdown.Content>

Right content column. Contains the search bar and the scrollable item list.

| Prop | Type | Default | Description | |---|---|---|---| | searchPlaceholder | string | "Search" | Placeholder text for the search input. | | emptyText | string | "Nothing to show here" | Text shown when Content has no children at all (empty state). The search bar is also hidden in this state. | | component | React component | — | Custom wrapper component. | | ...rest | any | — | Any additional props are forwarded to the content column <div> (or component). |


<Dropdown.Section>

Groups Dropdown.Group / Dropdown.Item elements under a nav scope.

| Prop | Type | Default | Description | |---|---|---|---| | forId | string | "__all__" | Matches a Dropdown.NavigationItem id. Defaults to "__all__", so it can be omitted when there's no navigation. Also accepts for (JSX alias). | | title | string | — | Section heading shown in displayMode="scroll". Sticks to the top of the scroll container while the section is in view. | | ...rest | any | — | Any additional props are forwarded to the section wrapper <div>. |

{/* With nav */}
<Dropdown.Section forId="fruits" title="Fruits">
  ...
</Dropdown.Section>

{/* Without nav — forId can be omitted */}
<Dropdown.Section>
  ...
</Dropdown.Section>

<Dropdown.Group>

A collapsible group of items with a colored left-border accent.

| Prop | Type | Default | Description | |---|---|---|---| | label | string | required | Group heading text. | | id | string | auto | Stable identifier for the imperative API (ref.current.selectAll(id)). Auto-derived from label if omitted ("Team Members""team_members"). Provide an explicit id when using the imperative API. | | icon | ReactNode \| FC | — | Icon displayed left of the group label in the header bar. | | color | string | auto | CSS color for the left accent bar. Auto-assigned from a built-in palette if omitted. | | defaultExpanded | boolean | — | Override the root-level defaultGroupExpanded for this specific group. | | showSelectAll | boolean | false | Shows a "Select all" checkbox item inside the group. | | selectAllPosition | "top" \| "bottom" | "bottom" | Position of the select-all item. | | emptyText | string | "Nothing to show here" | Text shown when the group has no children. | | noResultsText | string | "No results" | Text shown when a search query returns no matching items inside this group. Only applies when hideEmptyResults={false} on <Dropdown>; otherwise the group is removed entirely while filtering. | | component | React component | — | Custom wrapper component. | | ...rest | any | — | Any additional props are forwarded to the group wrapper <div> (or component). |


<Dropdown.Item>

A single selectable or checkable item.

| Prop | Type | Default | Description | |---|---|---|---| | id | string | required | Unique identifier for this item. | | type | "click" \| "checkbox" | "click" | Interaction mode. "click" triggers a select event; "checkbox" triggers a check event. | | icon | ReactNode \| FC | — | Icon displayed left of the item label. | | defaultChecked | boolean | false | Initial checked state (uncontrolled, type="checkbox" only). | | checkIcon | ReactNode \| FC | built-in ✓ | Custom check icon. | | component | React component | — | Custom component. Receives isSelected, isChecked, onClick. | | ...rest | any | — | Any additional props are forwarded to the item <div> (or component). onClick and onKeyDown are composed with the internal selection handlers. |

Items are automatically filtered when the user types in the search box.


fromConfig — config-driven rendering

Pass a plain JS object to the fromConfig prop on <Dropdown> to render the entire tree without writing JSX. Useful for server-driven UIs or stored configurations.

fromConfig and children cannot be used together — fromConfig takes precedence.

<Dropdown fromConfig={config} />

Config schema

Any unknown props at any level are forwarded as-is to the underlying component. This means you can pass data-* attributes, onClick, onMouseEnter, or any other prop directly in the config object:

// Example — data-test and onClick on a specific item
{ id: 'name', label: 'Name', 'data-test': 'field-name', onClick: handleClick }
const config = {
  // Root props (all optional)
  displayMode: 'scroll' | 'tab',
  defaultOpen: boolean,
  defaultGroupExpanded: boolean | 'first',
  hideOnSelection: boolean,
  onEvent: ({ type, payload, prev }) => any,
  useTranslationFunction: (text, payload) => string,
  groupHeaderStyle: 'accent' | 'light',
  autoFocusSearch: boolean,   // default true
  hideEmptyResults: boolean,  // default true
  // ...any extra props are spread onto <Dropdown>

  // Trigger — required
  trigger: ReactNode | string | {
    label: string,
    className?: string,
    component?: ComponentType,
  },

  // Panel (optional)
  panel?: {
    placement?: string,  // default 'bottom-start'
    offset?: number,  // default 8
    avoid?: string | Element | Ref | Array<string | Element | Ref>,
    placementPriority?: string[],  // e.g. ['bottom','top','right','left']
    beforePlace?: (panelEl: HTMLElement) => Promise<void>,
  },

  // Navigation column (optional)
  navigation?: { ... },   // legacy alias, still supported
  items?: [               // preferred root config for navigation items
    {
      id?: string,
      label: string,
      icon?: ReactNode | FC,
      title?: string,
      items?: [
        {
          id?: string,
          label?: string,
          icon?: ReactNode | FC,
          color?: string,
          defaultExpanded?: boolean,
          showSelectAll?: boolean,
          selectAllPosition?: 'top' | 'bottom',
          emptyText?: string,
          noResultsText?: string,
          items: [
            {
              id: string,
              label: string,
              icon?: ReactNode | FC,
              type?: 'click' | 'checkbox',
              defaultChecked?: boolean,
              checkIcon?: ReactNode | FC,
              actions?: ReactNode | ((item) => ReactNode),
              component?: ComponentType,
            },
          ],
        },
      ],
    },
  ],
  showAll?: boolean,
  allLabel?: string,
  allIcon?: ReactNode | FC,
  collapsed?: boolean,
  autoCollapse?: boolean | 'auto',

  // Content column — required
  content: {
    searchPlaceholder?: string,
    emptyText?: string,   // shown when no sections/groups are provided
    sections: [
      {
        for?: string,   // optional when section content lives under items[]
        title?: string,
        groups?: [
          {
            id?: string,
            label?: string,
            icon?: ReactNode | FC,
            color?: string,
            defaultExpanded?: boolean,
            showSelectAll?: boolean,
            selectAllPosition?: 'top' | 'bottom',
            emptyText?: string,
            noResultsText?: string,
            items: [
              {
                id: string,
                label: string,
                icon?: ReactNode | FC,
                type?: 'click' | 'checkbox',
                defaultChecked?: boolean,
                checkIcon?: ReactNode | FC,
                actions?: ReactNode | ((item) => ReactNode),
                component?: ComponentType,
              },
            ],
          },
        ],
        items?: [
          {
            id?: string,
            label?: string,
            icon?: ReactNode | FC,
            color?: string,
            defaultExpanded?: boolean,
            showSelectAll?: boolean,
            selectAllPosition?: 'top' | 'bottom',
            emptyText?: string,
            noResultsText?: string,
            items: [
              {
                id: string,
                label: string,
                icon?: ReactNode | FC,
                type?: 'click' | 'checkbox',
                defaultChecked?: boolean,
                checkIcon?: ReactNode | FC,
                actions?: ReactNode | ((item) => ReactNode),
                component?: ComponentType,
                // ...any extra props (data-*, onClick, onMouseEnter, …) are forwarded to <DropdownItem>
              },
            ],
          },
        ],
      },
    ],
  },
}

Example

import { Dropdown } from '@diabolic/hangover'

const config = {
  trigger: 'Select fields',
  showAll: true,
  items: [
    {
      id: 'basic',
      label: 'Basic',
      title: 'Basic',
      items: [
        {
          label: 'Identity',
          items: [
            { id: 'name', label: 'Name' },
            { id: 'email', label: 'Email' },
          ],
        },
      ],
    },
    {
      id: 'advanced',
      label: 'Advanced',
      title: 'Advanced',
      items: [
        {
          label: 'System',
          items: [
            { id: 'created-at', label: 'Created at' },
            { id: 'updated-at', label: 'Updated at' },
          ],
        },
      ],
    },
  ],
  content: {
    searchPlaceholder: 'Search fields...',
  },
}

export default function App() {
  return <Dropdown fromConfig={config} />
}

Events

All events are delivered via the onEvent prop on <Dropdown>.

<Dropdown onEvent={({ type, payload, prev }) => {
  console.log(type, payload)
}}>

| Event type | payload | prev | Description | |---|---|---|---| | open | { trigger } | — | Panel opened. trigger: "click" \| "imperative". | | close | { trigger } | — | Panel closed. trigger: "click" \| "outside" \| "escape" \| "imperative". | | select | { id, label, groupId, groupLabel } | { id, label } \| null | An item was clicked (type="click"). | | check | { id, label, groupId, groupLabel } | { checked } | A checkbox item was toggled. | | selectAll | { groupId, groupLabel, itemIds } | { checked } | Select-all was toggled. | | navChange | { id } | { id } | Active nav item changed. | | search | { query } | { query } | Search input changed. | | groupToggle | { groupId, groupLabel, expanded } | — | A group was expanded or collapsed. |

Cancelling an event

Return null from onEvent to cancel the state update:

<Dropdown onEvent={({ type, payload }) => {
  if (type === 'select' && payload.id === 'locked') return null // cancel
}}>

Native DOM events

Each event also fires a native CustomEvent on the trigger element:

trigger.addEventListener('HO:select', (e) => {
  console.log(e.detail) // { payload, prev }
})

Translation

Pass a useTranslationFunction to <Dropdown> to localize the UI. When provided, every user-facing string is routed through it — including built-in defaults like "Search", "Select all", "No results" and "Nothing to show here", as well as your own labels (group names, item labels, nav items, section/panel titles).

The function receives the original string and returns the translated one:

const translations = {
  'Search': 'Ara',
  'Select all': 'Tümünü seç',
  'No results': 'Sonuç yok',
  'Nothing to show here': 'Gösterilecek bir şey yok',
  'Fruits': 'Meyveler',
  'Apple': 'Elma',
}

<Dropdown useTranslationFunction={(text) => translations[text] ?? text}>
  {/* ... */}
</Dropdown>

Dynamic values

When a string contains dynamic values, they are passed as a payload object in the second argument instead of being concatenated into the string. The default string uses {placeholder} tokens that map to payload keys:

<Dropdown
  useTranslationFunction={(text, payload) => {
    // e.g. text = "{label} — {action}", payload = { label: "Fruits", action: "collapse" }
    if (text === '{label} — {action}') {
      return `${payload.label}: ${payload.action === 'collapse' ? 'kapat' : 'aç'}`
    }
    return translations[text] ?? text
  }}
>
  {/* ... */}
</Dropdown>

If no useTranslationFunction is provided, strings render as-is and any {placeholder} tokens are interpolated from the payload automatically.

Also available via fromConfig as useTranslationFunction.


Imperative API

Attach a ref to <Dropdown> to control it programmatically from outside the tree:

const dropdownRef = useRef()

<Dropdown ref={dropdownRef}>
  ...
</Dropdown>

| Method | Returns | Description | |---|---|---| | open() | — | Open the panel. | | close() | — | Close the panel. | | toggle() | — | Toggle open/close. | | isOpen() | boolean | Current open state. | | getSelected() | { id, label } \| null | Currently selected item. | | getChecked() | Map<id, boolean> | All checkbox states. | | getActiveNavItem() | string | Active nav item id. | | setSearch(query) | — | Programmatically set the search query. | | selectAll(groupId, checked?) | — | Toggle or force the select-all state of a group. groupId matches the id prop on <Dropdown.Group>. Omit checked to toggle; pass true/false to force. |

const ref = useRef()

// toggle
ref.current.selectAll('metrics')

// force on / force off
ref.current.selectAll('metrics', true)
ref.current.selectAll('metrics', false)

useDropdown Hook

Use useDropdown() to read state and trigger actions from inside the dropdown tree — for example inside a custom component passed via the component prop.

import { useDropdown } from '@diabolic/hangover'

function MyCustomItem({ isSelected, onClick, children }) {
  const { searchQuery, activeNavId, close } = useDropdown()
  // ...
}

<Dropdown.Item id="foo" component={MyCustomItem}>Foo</Dropdown.Item>

Must be called inside a <Dropdown> subtree.

Returns

Reactive state — triggers re-render when the value changes:

| Property | Type | Description | |---|---|---| | isOpen | boolean | Panel open state. | | selectedItem | { id, label } \| null | Currently selected item. | | checkedItems | Map<id, boolean> | All checkbox states. | | activeNavId | string | Active navigation item id. | | activeNavLabel | string | Label of the active navigation item. | | searchQuery | string | Current search input value. | | displayMode | "scroll" \| "tab" | Display mode of the dropdown. | | darkMode | boolean | Dark mode state. |

Actions — stable references, safe to use in useEffect / useCallback deps:

| Method | Description | |---|---| | open() | Open the panel. | | close() | Close the panel. | | toggle() | Toggle open/closed. | | setSearch(query) | Update the search query. | | fireEvent(type, payload) | Fire any internal event. Useful for advanced / unforeseen scenarios. Return null from onEvent to cancel. |

Example — custom trigger with current state

import { useDropdown } from '@diabolic/hangover'

function SmartTrigger() {
  const { isOpen, selectedItem, toggle } = useDropdown()

  return (
    <button onClick={toggle}>
      {selectedItem ? selectedItem.label : 'Select a field'}
      {isOpen ? ' ▲' : ' ▼'}
    </button>
  )
}

<Dropdown>
  <Dropdown.Trigger>
    <SmartTrigger />
  </Dropdown.Trigger>
  ...
</Dropdown>

Example — close panel from inside a custom item

function MyItem({ isSelected, onClick, children }) {
  const { close } = useDropdown()

  return (
    <div onClick={() => { onClick(); close() }}>
      {children}
    </div>
  )
}

Example — react to search query inside a custom component

function MyContent({ children }) {
  const { searchQuery, activeNavLabel } = useDropdown()

  return (
    <div>
      {searchQuery && <p>Results for "{searchQuery}" in {activeNavLabel}</p>}
      {children}
    </div>
  )
}

Recipes

With icons on groups and items

function IconUser() {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
      <path fillRule="evenodd" clipRule="evenodd" d="M8 2a2.667 2.667 0 1 1 0 5.333A2.667 2.667 0 0 1 8 2Zm0 6.667c-3.2 0-5.333 1.6-5.333 2.666V12h10.666v-.667C13.333 10.267 11.2 8.667 8 8.667Z" fill="currentColor" />
    </svg>
  )
}

function IconMail() {
  return (
    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
      <path fillRule="evenodd" clipRule="evenodd" d="M2 3.333A.667.667 0 0 1 2.667 2.667h10.666A.667.667 0 0 1 14 3.333v9.334a.667.667 0 0 1-.667.666H2.667A.667.667 0 0 1 2 12.667V3.333Zm1.333.92V12h9.334V4.253L8 7.92 3.333 4.253ZM12.24 4H3.76L8 6.747 12.24 4Z" fill="currentColor" />
    </svg>
  )
}

<Dropdown.Group label="Contact" icon={IconUser}>
  <Dropdown.Item id="name" icon={IconUser}>Full Name</Dropdown.Item>
  <Dropdown.Item id="email" icon={IconMail}>Email Address</Dropdown.Item>
</Dropdown.Group>

Icons inherit the item's text color via currentColor — they automatically adapt to hover, active, and dark mode states.


Dark mode

<Dropdown darkMode>
  <Dropdown.Trigger><button>Open</button></Dropdown.Trigger>
  <Dropdown.Panel>
    ...
  </Dropdown.Panel>
</Dropdown>

Panel title

<Dropdown.Panel title="Select a field">
  ...
</Dropdown.Panel>

Empty content state

When <Dropdown.Content> has no children, the search bar is hidden and an empty message is shown:

<Dropdown.Content emptyText="No fields available">
  {/* no children */}
</Dropdown.Content>

Search no-results per group

<Dropdown.Group label="Metrics" noResultsText="No metrics match your search">
  <Dropdown.Item id="revenue">Revenue</Dropdown.Item>
  <Dropdown.Item id="sessions">Sessions</Dropdown.Item>
</Dropdown.Group>

Controlled search query

Drive the search input from outside — connect it to your own state or a URL param:

const [query, setQuery] = useState('')

<input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." />

<Dropdown
  searchQuery={query}
  onEvent={({ type, payload }) => {
    if (type === 'search') setQuery(payload.query)
  }}
>
  ...
</Dropdown>

Or use defaultSearchQuery to set the initial value without controlling it:

<Dropdown defaultSearchQuery="rev">
  ...
</Dropdown>

With left navigation (scroll mode)

<Dropdown displayMode="scroll">
  <Dropdown.Trigger><button>Browse</button></Dropdown.Trigger>
  <Dropdown.Panel>
    <Dropdown.Navigation showAll>
      <Dropdown.NavigationItem id="fruits">Fruits</Dropdown.NavigationItem>
      <Dropdown.NavigationItem id="vegetables">Vegetables</Dropdown.NavigationItem>
    </Dropdown.Navigation>
    <Dropdown.Content>
      <Dropdown.Section forId="fruits" title="Fruits">
        <Dropdown.Group label="Citrus">
          <Dropdown.Item id="orange">Orange</Dropdown.Item>
          <Dropdown.Item id="lemon">Lemon</Dropdown.Item>
        </Dropdown.Group>
      </Dropdown.Section>
      <Dropdown.Section forId="vegetables" title="Vegetables">
        <Dropdown.Group label="Leafy">
          <Dropdown.Item id="spinach">Spinach</Dropdown.Item>
        </Dropdown.Group>
      </Dropdown.Section>
    </Dropdown.Content>
  </Dropdown.Panel>
</Dropdown>

Checkbox mode with select-all

<Dropdown>
  <Dropdown.Trigger><button>Select fields</button></Dropdown.Trigger>
  <Dropdown.Panel>
    <Dropdown.Content>
      <Dropdown.Section>
        <Dropdown.Group label="Metrics" showSelectAll>
          <Dropdown.Item id="revenue" type="checkbox">Revenue</Dropdown.Item>
          <Dropdown.Item id="sessions" type="checkbox">Sessions</Dropdown.Item>
          <Dropdown.Item id="bounce" type="checkbox">Bounce rate</Dropdown.Item>
        </Dropdown.Group>
      </Dropdown.Section>
    </Dropdown.Content>
  </Dropdown.Panel>
</Dropdown>

Controlled — prevent selection

<Dropdown onEvent={({ type, payload }) => {
  if (type === 'select' && payload.id === 'locked') {
    alert('This item is locked')
    return null // cancel
  }
}}>
  ...
</Dropdown>

Collapse all groups by default

<Dropdown defaultGroupExpanded={false}>
  ...
</Dropdown>

Expand only the first group

<Dropdown defaultGroupExpanded="first">
  ...
</Dropdown>

Custom panel placement and offset

{/* Open above the trigger, aligned to the right edge, 16px gap */}
<Dropdown.Panel placement="top-end" offset={16}>
  ...
</Dropdown.Panel>

Without a Trigger

When you cannot wrap the toggle button inside <Dropdown>, use an external anchor ref and control the panel via the Imperative API:

const dropdownRef = useRef()
const buttonRef = useRef()

// The button lives anywhere in the tree — outside <Dropdown> if needed
<button ref={buttonRef} onClick={() => dropdownRef.current.toggle()}>
  Open
</button>

<Dropdown ref={dropdownRef}>
  <Dropdown.Panel anchor={buttonRef}>
    <Dropdown.Content>
      <Dropdown.Section>
        <Dropdown.Group label="Items">
          <Dropdown.Item id="one">One</Dropdown.Item>
        </Dropdown.Group>
      </Dropdown.Section>
    </Dropdown.Content>
  </Dropdown.Panel>
</Dropdown>

Dropdown.Trigger is not needed in this pattern. The anchor ref is used for positioning and outside-click detection.


Collapsible / auto-collapsing navigation

{/* Start collapsed */}
<Dropdown.Navigation collapsed>
  ...
</Dropdown.Navigation>

{/* Auto-collapse when the viewport is too narrow */}
<Dropdown.Navigation autoCollapse>
  ...
</Dropdown.Navigation>

{/* Start collapsed, expand on hover or keyboard focus */}
<Dropdown.Navigation collapsed autoCollapse="auto">
  ...
</Dropdown.Navigation>

The autoCollapse threshold is --hangover-nav-width + --hangover-content-max-width. Override either token to tune the breakpoint.


Custom component slot

Every compound component accepts a component prop to swap the root element:

<Dropdown.Item id="foo" component={MyCustomRow}>
  Foo
</Dropdown.Item>

CSS Tokens

All visual properties are configurable via CSS custom properties:

.hangoverDropdown {
  --hangover-font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
  --hangover-font-size-base: 14px;
  --hangover-font-size-sm: 12px;

  /* Text */
  --hangover-color-text: #0a1551;
  --hangover-color-text-muted: #8a9ab5;

  /* Backgrounds */
  --hangover-color-bg-panel: #ffffff;
  --hangover-color-bg-title: #eceef5;
  --hangover-color-bg-nav: #F5F6FC;
  --hangover-color-bg-nav-hover: #EAECF5;
  --hangover-color-bg-nav-active: #E0E3EF;
  --hangover-color-bg-hover: #f3f3fe;
  --hangover-color-bg-hover-dark: #eaeaf9;
  --hangover-color-bg-selected: #eef2ff;
  --hangover-color-bg-checked: #EDF8FF;

  /* Border & misc */
  --hangover-color-border: #e2e8f0;
  --hangover-color-search-ph: #979DC6;
  --hangover-color-search-icon: #343C6A;
  --hangover-color-focus: #3b82f6;
  --hangover-group-default-color: #16a34a;

  /* Shape */
  --hangover-radius-panel: 4px;
  --hangover-radius-item: 4px;
  --hangover-radius-nav-item: 4px;

  --hangover-shadow-panel:
    0 8px 16px 0 rgba(84, 95, 111, 0.16),
    0 2px 4px 0 rgba(37, 45, 91, 0.04);

  /* Layout */
  --hangover-nav-width: 172px;
  --hangover-content-max-width: 240px;
  --hangover-panel-nonav-width: 240px;
  --hangover-list-max-height: 280px;

  --hangover-transition: 330ms ease;
}

Dark mode

Pass darkMode to <Dropdown> to apply a pre-built dark palette:

<Dropdown darkMode>
  ...
</Dropdown>

To customise dark mode colors, override the tokens inside .hangoverDropdown--dark:

.hangoverDropdown--dark {
  --hangover-color-bg-panel: #1a1d2e;
  --hangover-color-text: #dde1f5;
  /* ... */
}

Storybook

Explore all components and interactions in the live demo.

Run locally:

npm run storybook

Build the static demo site (output in storybook-static/):

npm run build-storybook

Stories are located in src/stories/:

| Story | Description | |---|---| | 1. Basic | Basic usage, with/without nav, single-section auto-transform, open by default, empty state | | 2. Scroll Spy | Scroll-spy with nav icons, collapsible navigation, long text stress test | | 3. Tab Mode | displayMode="tab" | | 4. Checkbox | Checkbox items, select-all | | 5. Controlled | Controlled state, external anchor + imperative API | | 6. Events | Live event stream, cancel event | | 7. Placement | All panel placement variants, auto-placement with scroll | | 8. From Config | fromConfig prop — config-driven rendering with and without auto-collapse |


License

MIT