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.
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
| Pattern | Trigger stays visible when open? | Keyboard model | Used here for |
|---|---|---|---|
Disclosure dropdown (DownloadMenu) | Yes, panel floats below it | Tab through links in order | A short, link-only list anchored to one control |
Full-screen overlay (PreviewModal, SavedTemplates) | No, overlay covers the trigger | Tab within a focus-trapped region, Escape closes | Content too large or too interactive for a floating panel |
Flat link (Header's ex-"Resources" item) | N/A — no panel at all | Tab lands directly on the destination | A menu whose real content collapsed to one item |
| Chip/segmented control (edition pickers elsewhere in the catalog UI) | N/A — all options visible at once | Tab between chips, no open/close state | A 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
| Symptom | Cause | Fix |
|---|---|---|
| The panel opens in the wrong spot, or off-screen | No relative on the intended anchor, so absolute falls back to a farther ancestor | Confirm the immediate wrapper (not a distant parent) carries relative |
| Screen readers can't find the panel's links until it's open | The panel unmounts ({open && <Panel/>}) instead of toggling hidden | Keep the panel mounted and toggle the hidden class, as DownloadMenu does |
| Clicking elsewhere on the page doesn't close the menu | No outside-click listener, or one that isn't scoped to open | Add a pointerdown listener on document while open, removed on close |
| Escape closes the panel but focus disappears | Missing the explicit .focus() call back to the trigger button | Return 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 control | Hover and click folded into one boolean | Track them separately and OR them for the visible open state |
| A three-item dropdown for options that would fit as plain buttons | Reaching for the pattern by default rather than by option count | Compare 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.
