Skip to content
Twilight React Playground
ثيم رائدaren

Dropdown

componentBeginnerserverbrowserlive demo

A menu that opens under a button and closes when the shopper clicks elsewhere, built from Trigger, Menu, Item and Divider parts.

import { Dropdown, DropdownProps, DropdownTriggerProps, DropdownMenuProps, DropdownItemProps } from '@salla.sa/twilight-theme-engine/components/dropdown';

In plain words

A dropdown is a short list of choices that appears next to a button, like "Sort by" on a category page. Clicking or touching anywhere outside it calls your onClose.

As with the modal, your component keeps isOpen in state. Pass it to Dropdown and to Dropdown.Menu: the menu does not read it from its parent and stays hidden without it. Choosing an item does not close the menu either; close it yourself in the item's onClick.

Signature

function Dropdown(props: DropdownProps): JSX.Element  // <div class="relative inline-block">

interface DropdownProps {
  isOpen: boolean;      // only used to decide whether a click outside calls onClose
  onClose: () => void;  // called on mousedown or touchstart outside
  children: ReactNode;
  position?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'; // not read: set it on Menu
  className?: string;
}

Dropdown.Trigger(props: DropdownTriggerProps)           // inline-block <div>
Dropdown.Menu(props: DropdownMenuProps)                 // <ul role="menu">, null while closed
Dropdown.Item(props: DropdownItemProps)                 // <li role="menuitem">
Dropdown.Divider(props: HTMLAttributes<HTMLLIElement>)  // only className is used

interface DropdownTriggerProps extends HTMLAttributes<HTMLDivElement> { children: ReactNode }
interface DropdownMenuProps extends HTMLAttributes<HTMLUListElement> {
  children: ReactNode;
  isOpen?: boolean;                      // default false
  position?: DropdownProps['position'];  // default 'bottom-start'
}
interface DropdownItemProps extends HTMLAttributes<HTMLLIElement> {
  children: ReactNode;
  href?: string;          // renders a plain <a href>
  onClick?: () => void;
  disabled?: boolean;
  active?: boolean;
}

Try it live

A sort menu. Clicking anywhere outside it closes it; choosing an item closes it only because the item says so.Try this: turn off "Close on select" and pick a sort: the menu stays open, because items never close it themselves.
Storefront canvas · en · LTR
Controls
Close on select
Disable "Best selling"
What a theme writes
import { useState } from 'react';
import { Dropdown } from '@salla.sa/twilight-theme-engine/components/dropdown';

const SORTS = [
  { value: 'newest', label: 'Newest' },
  { value: 'price-asc', label: 'Price: low to high' },
];

export function SortMenu({ sort, onSort }: { sort: string; onSort: (value: string) => void }) {
  const [isOpen, setIsOpen] = useState(false);
  const close = () => setIsOpen(false);

  return (
    <Dropdown isOpen={isOpen} onClose={close}>
      <Dropdown.Trigger>
        <button type="button" className="btn btn--outline-primary" onClick={() => setIsOpen(!isOpen)}>
          Sort
        </button>
      </Dropdown.Trigger>
      <Dropdown.Menu isOpen={isOpen}>
        {SORTS.map((option) => (
          <Dropdown.Item
            key={option.value}
            active={option.value === sort}
            onClick={() => {
              onSort(option.value);
              close();
            }}
          >
            {option.label}
          </Dropdown.Item>
        ))}
        <Dropdown.Divider />
        <Dropdown.Item disabled onClick={() => onSort('best-selling')}>
          Best selling
        </Dropdown.Item>
      </Dropdown.Menu>
    </Dropdown>
  );
}

Example

app/components/category/SortMenu.tsx
import { useState } from 'react';
import { Dropdown } from '@salla.sa/twilight-theme-engine/components/dropdown';

const SORTS = [
  { value: 'newest', label: 'Newest' },
  { value: 'price-asc', label: 'Price: low to high' },
  { value: 'price-desc', label: 'Price: high to low' },
];

export function SortMenu({ sort, onSort }: { sort: string; onSort: (value: string) => void }) {
  const [isOpen, setIsOpen] = useState(false);
  const close = () => setIsOpen(false);

  return (
    <Dropdown isOpen={isOpen} onClose={close}>
      <Dropdown.Trigger>
        <button type="button" className="btn btn--outline-primary" onClick={() => setIsOpen(!isOpen)}>
          Sort
        </button>
      </Dropdown.Trigger>
      {/* The Menu needs its own isOpen, and takes the position */}
      <Dropdown.Menu isOpen={isOpen} position="bottom-end">
        {SORTS.map((option) => (
          <Dropdown.Item
            key={option.value}
            active={option.value === sort}
            onClick={() => {
              onSort(option.value);
              close(); // items never close the menu themselves
            }}
          >
            {option.label}
          </Dropdown.Item>
        ))}
      </Dropdown.Menu>
    </Dropdown>
  );
}

How it behaves

  • Click-away comes from useClickAway in @uidotdev/usehooks: mousedown and touchstart listeners on document for as long as the dropdown is mounted. It calls onClose only while isOpen is true. Clicks on the trigger are inside, so a toggling trigger works.

  • Positions use logical classes (top-full start-0, top-full end-0, bottom-full start-0, bottom-full end-0), so start and end flip on right-to-left pages. The menu is absolute z-40 min-w-[10rem], so an ancestor with overflow: hidden or auto clips it.

  • An item without href is an <li role="menuitem" tabIndex={0}> that runs onClick on click, Enter or Space; disabled removes it from Tab order and ignores the click. active only adds the highlighted background.

  • There is no Escape key handling, arrow-key movement or focus management, whatever the JSDoc says about keyboard navigation. Tabbing out of an open menu leaves it open.

  • The menu's animate-in fade-in slide-in-from-top-1 classes come from the tailwindcss-animate plugin, which the reference theme's Tailwind config does not load, so the menu appears without animation.

  • No provider is needed and a closed menu renders nothing, so it is safe on the server. The same export is also at @salla.sa/twilight-theme-engine/dropdown.

Gotchas

  • Dropdown.Menu defaults isOpen to false and does not read the parent's. The JSDoc example at the top of the engine's Dropdown.tsx renders <Dropdown.Menu> without it, and that menu never appears. Pass isOpen={isOpen} to the Menu.

  • position on Dropdown type-checks but is never read. Put it on Dropdown.Menu.

  • Selecting an item does not close the menu. Call your close function in each item's onClick.

  • An item with href renders a plain <a href>: its onClick is dropped, and disabled only fades it (the link still navigates). It is also not the engine Link, so no locale is added, and on localhost and the preview host a root-relative path's first segment is read as a store username. For store pages, use onClick with useNavigate() from @salla.sa/twilight-theme-engine/providers.

Related

Source and docs