Skip to main content
ASoc
Tutorial

Tailwind Dropdown: One Real Menu, and a Second One Rejected in a Comment

This storefront has exactly one real dropdown, and its header explicitly rejected a second in a code comment. What DownloadMenu gets right that a div toggle usually skips.

The ASoc Team9 min read

A Tailwind dropdown is usually built from an absolute-positioned panel toggled by a boolean, anchored to a relative wrapper around its trigger button — Tailwind has no dropdown component of its own, only the utilities to lay one out. This codebase has exactly one real dropdown, and its own header explicitly does not have one, by a decision left in a code comment rather than an accident of scope.

The short answer

There's no tailwind dropdown class — you compose one from relative on the trigger's wrapper, absolute on the panel, a hidden/visibility toggle driven by component state, and aria-expanded/aria-controls wiring the button to the panel it opens. This codebase's one implementation (DownloadMenu) also closes on outside click, closes and returns focus on Escape, and keeps the panel in the DOM with hidden rather than unmounting it — four behaviors a plain "toggle a div" tutorial usually skips.

The one real dropdown in this codebase

DownloadMenu is the edition picker an owner sees on a card's cover art and inside the preview modal's Download button. Strip it to the layout skeleton and it's the pattern every Tailwind dropdown tutorial reaches for:

// src/components/molecules/DownloadMenu.tsx
<div className={`relative ${className}`} ref={wrapperRef}>
  <button
    aria-controls={panelId}
    aria-expanded={open}
    onClick={() => (open ? close() : setPinned(true))}
    ref={buttonRef}
    type="button"
  >
    Download
  </button>

  <div className={`absolute top-full right-0 z-30 pt-2 ${open ? "" : "hidden"}`} id={panelId}>
    <div className="min-w-52 overflow-hidden rounded-xl border border-stroke-secondary bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
      {/* one <a> per option */}
    </div>
  </div>
</div>

relative on the outer wrapper, absolute top-full right-0 on the panel — the panel sits flush against the trigger's bottom edge, right-aligned to it, positioned against that relative box rather than the page. z-30 clears the card's own hover overlay, which the CLAUDE.md convention for this codebase's cards deliberately keeps below interactive controls. None of this is a Tailwind primitive; it's four ordinary utilities arranged the way a dropdown needs them arranged.

Four behaviors the skeleton above leaves out

It stays in the DOM. The panel toggles hidden, not a conditional render:

{/* `hidden` rather than unmounting: the panel keeps its place in the tab
    order calculation and the links stay measurable for tests. */}

A component that renders {open && <Panel />} unmounts the links every time the menu closes, which means a test (or a screen reader building its accessibility tree) can't find them until the menu is open again. hidden keeps the DOM node present — display: none still removes it from layout and the visible tab order, but the element and its children exist continuously, which is what makes "the links stay measurable for tests" literally true rather than aspirational.

It closes on outside click and on Escape, and Escape returns focus:

useEffect(() => {
  if (!open) return;
  const onPointerDown = (e: PointerEvent) => {
    if (!wrapperRef.current?.contains(e.target as Node)) close();
  };
  const onKey = (e: KeyboardEvent) => {
    if (e.key !== "Escape") return;
    e.stopPropagation();
    close();
    buttonRef.current?.focus();
  };
  document.addEventListener("pointerdown", onPointerDown);
  document.addEventListener("keydown", onKey, true);
  return () => {
    document.removeEventListener("pointerdown", onPointerDown);
    document.removeEventListener("keydown", onKey, true);
  };
}, [open]);

Both listeners are scoped to document and only attached while open is true — a dropdown that's always listening for every click on the page, whether or not it's open, is a common source of the "why is this component slow" bug nobody traces back to an idle panel. buttonRef.current?.focus() after Escape is the line a hidden-panel implementation is most likely to skip: without it, focus is left on a link that just vanished from the tab order, and the next Tab press starts from wherever the browser decides an orphaned focus target should go — usually the top of the document.

It's a disclosure, not a menu, and the doc comment says so on purpose:

It's a disclosure, not an ARIA menu, precisely so that link-by-link
tabbing is the correct interaction rather than roving arrow keys the
panel doesn't implement.

The ARIA menu pattern requires role="menu", role="menuitem" on every row, and arrow-key navigation between them instead of Tab. Implementing that correctly is real work — and it's the wrong pattern here, because every row in this panel is an ordinary link to a different URL, not an application command. Reaching for role="menu" because the component looks like a menu, without implementing the keyboard contract that role promises, is worse than not using the role at all: it tells assistive tech to expect arrow-key behavior that was never built.

Hover opens it too, and hover and click are tracked as two separate booleans, not one:

const [hovered, setHovered] = useState(false);
const [pinned, setPinned] = useState(false);
const open = hovered || pinned;

Folding these into a single open toggle looks equivalent and isn't. The pointer is, by definition, already over the button at the moment you click it — so a single toggle would immediately close the panel that hover had just opened, and the click would read as broken. Two booleans, OR'd together for the visible state, is what makes "click to pin, hover to preview" both work without fighting each other.

The header's dropdown is a comment, not a component

Header.tsx carries no dropdown at all, and the reason is written where the dropdown would have gone:

{/* No "Resources" dropdown: its only non-duplicate entry was Blog.
    "Documentation" repeated the Docs link two items to its left,
    and "Support" repeated the Support button in the actions row,
    so the menu is flattened to the one destination it added. */}

A "Resources" dropdown was apparently built or planned, then removed once its contents turned out to be mostly duplicate links to destinations the nav bar already had. What survived is a flat link where the trigger would have been — the same relative/absolute/aria-expanded machinery DownloadMenu uses would have been correct engineering spent on a menu with one non-redundant item. The comment is the part worth keeping: it records why the option was rejected, so nobody re-adds a three-item dropdown for one real link the next time the nav grows.

Comparison: dropdown vs. the alternatives already in this codebase

PatternTrigger stays visible when open?Keyboard modelUsed here for
Disclosure dropdown (DownloadMenu)Yes, panel floats below itTab through links in orderA short, link-only list anchored to one control
Full-screen overlay (PreviewModal, SavedTemplates)No, overlay covers the triggerTab within a focus-trapped region, Escape closesContent too large or too interactive for a floating panel
Flat link (Header's ex-"Resources" item)N/A — no panel at allTab lands directly on the destinationA menu whose real content collapsed to one item
Chip/segmented control (edition pickers elsewhere in the catalog UI)N/A — all options visible at onceTab between chips, no open/close stateA small, fixed set of mutually exclusive choices worth showing without a click

The row worth sitting with is the last one: a dropdown is the right tool specifically when the option list is longer than comfortably fits inline and doesn't need to stay visible at rest. DownloadMenu qualifies — a product can ship in five framework editions, and showing five buttons on every card would crowd the grid. A binary or three-way choice usually doesn't qualify, which is why nothing else in this codebase reaches for the same pattern.

Mistakes and how they show up

SymptomCauseFix
The panel opens in the wrong spot, or off-screenNo relative on the intended anchor, so absolute falls back to a farther ancestorConfirm the immediate wrapper (not a distant parent) carries relative
Screen readers can't find the panel's links until it's openThe panel unmounts ({open && <Panel/>}) instead of toggling hiddenKeep the panel mounted and toggle the hidden class, as DownloadMenu does
Clicking elsewhere on the page doesn't close the menuNo outside-click listener, or one that isn't scoped to openAdd a pointerdown listener on document while open, removed on close
Escape closes the panel but focus disappearsMissing the explicit .focus() call back to the trigger buttonReturn focus to the trigger in the same handler that closes the panel
The dropdown and a hover-preview overlay both try to be "the" interaction on one controlHover and click folded into one booleanTrack them separately and OR them for the visible open state
A three-item dropdown for options that would fit as plain buttonsReaching for the pattern by default rather than by option countCompare against a flat link or a chip row before building the panel

Frequently asked questions

Is there a Tailwind CSS dropdown component? No — Tailwind Plus, Flowbite, Preline, and similar libraries publish pre-built dropdown markup styled with Tailwind classes, but the framework itself ships only the layout primitives (relative, absolute, hidden, transitions). This codebase's one dropdown is hand-built from those, not pulled from a library.

Should a Tailwind dropdown use role="menu"? Only if it behaves like one — roving arrow-key focus between menuitems, typically for in-page commands rather than navigation links. A panel of plain links, like DownloadMenu's, is correctly a disclosure: Tab moves through the links in order, and no extra ARIA role is needed beyond aria-expanded/aria-controls on the trigger.

Does the dropdown panel need absolute, or would fixed work? absolute, when the panel should scroll away with its trigger — which is the normal case, including this one. Reach for fixed only when the panel must stay anchored to the viewport regardless of page scroll, which is a full-screen overlay's job, not a dropdown's.

When is a dropdown the wrong choice? When the option count is small enough to show inline without crowding the layout, and showing all options at rest is more useful than hiding them behind a click — this codebase's own header removed a three-item dropdown for exactly that reason once two of the three turned out to duplicate links already on the page.

Templates in this post

ASoc Surge markets a neural-network startup with tabbed capabilities and a two-tier pricing table, ASoc Synth is an AI-workspace SaaS site with a six-tool feature grid and role-based use cases, and ASoc Tempo markets a time-tracking product with a live-style dashboard preview and a three-tier pricing table — all built on the same disclosure and overlay conventions audited above.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial9 min read

Tailwind Flex: 6 of 12 Utilities, 296 Call Sites

296 flex-utility call sites across 68 files use 6 of Tailwind's 12 flex classes — the other six, all per-child grow/shrink tuning, are never needed.

Read more
Tutorial10 min read

Tailwind Font Weight: 9 Named Steps, 4 This Codebase Uses

193 font-weight utility calls across this codebase, spanning only 4 of Tailwind's 9 named steps — traced to two atoms that set the hierarchy once.

Read more