Pre-built structural CSS for common UI patterns. No colors, no sizing — just behavior. Style with Dopamine classes.
Components provide structure only — display, transitions, open/close behavior.
All sizing and colors come from Dopamine classes in your HTML.
Available components: accordion, modal, menu, menu-drawer, tabs, dropdown, collapse. Shared helper: scroll-lock (opt-in body scroll lock for modal + menu-drawer). Form controls (input, checkbox, radio, switch) live on the Forms page.
Accordion using native <details> / <summary> elements. Works without JavaScript — optional accordion.js adds smooth close animation. Structure comes from addons/components/accordion/accordion.scss — styling via Dopamine classes.
<div class="accordion">
<details class="accordion__item">
<summary class="accordion__title">Question</summary>
<div class="accordion__body">
<div class="accordion__content">
<div>Answer</div>
</div>
</div>
</details>
</div>
CSS + optional JS for smooth close animation:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/accordion/accordion.css">
<script src="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/accordion/accordion.js" defer></script>
Or via Sass:
@use 'dopamine-fluid/addons/components/accordion/accordion';
<div class="accordion">
<details class="accordion__item">
<summary class="accordion__title p-12-24 fs-16-20 fw-bold">
Question
</summary>
<div class="accordion__body">
<div class="accordion__content">
<div class="p-12-24 fs-14-18 lh-1.5">Answer</div>
</div>
</div>
</details>
</div>
A modal dialog with overlay, ESC key support, and click-outside-to-close. Requires a tiny JS file (~15 lines). Two transition options: fade+scale or slide-down.
The backdrop dims to 50% black out of the box. Override it with --modal-backdrop — no need to touch the component CSS:
:root { --modal-backdrop: rgb(0 0 0 / 0.8); }
<button data-modal-open="my-modal">Open</button>
<div id="my-modal" class="modal">
<div class="modal__overlay"></div>
<div class="modal__dialog">
<button data-modal-close class="modal__close">×</button>
Content here
</div>
</div>
CSS file + JS file:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/modal/modal.css">
<script src="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/modal/modal.js" defer></script>
Or via Sass:
@use 'dopamine-fluid/addons/components/modal/modal';
<button data-modal-open="my-modal" class="p-12-24 fs-14-18 fw-bold radius-8">
Open Modal
</button>
<div id="my-modal" class="modal">
<div class="modal__overlay"></div>
<div class="modal__dialog modal__dialog--fade maxw-600 p-24-48 radius-16">
<button data-modal-close class="modal__close fs-24 p-8">×</button>
<h2 class="fs-24-48 mb-12-24 fw-bold">Title</h2>
<p class="fs-14-18">Content here.</p>
</div>
</div>
modal__dialog--fade — fade + scale transition
modal__dialog--slide — slide down transition
maxw-* — controls modal width (e.g. maxw-400, maxw-600, maxw-800)
A side-drawer menu for mobile navigation. Full width, full height, slides from the left with overflow-y: auto for scrollable content. On desktop (≥768px) the drawer is disabled and the nav displays inline. Requires a tiny JS file (~10 lines).
<nav class="menu">
<button class="menu__toggle">☰</button>
<div class="menu__overlay"></div>
<div class="menu__drawer">
<ul>
<li><a href="#">Link</a></li>
</ul>
</div>
</nav>
CSS file + JS file:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/menu/menu.css">
<script src="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/menu/menu.js" defer></script>
Or via Sass:
@use 'dopamine-fluid/addons/components/menu/menu';
The drawer switches to inline nav at 768px by default. Override via Sass:
@use 'dopamine-fluid/addons/components/menu/menu' with ($menu-bp: 992px);
<nav class="menu">
<button class="menu__toggle p-8 fs-24">☰</button>
<div class="menu__overlay"></div>
<div class="menu__drawer p-24-48">
<ul class="flex-col gap-8-16">
<li><a class="fs-16-20 fw-bold" href="#">Home</a></li>
<li><a class="fs-16-20" href="#">About</a></li>
<li><a class="fs-16-20" href="#">Contact</a></li>
</ul>
</div>
</nav>
menu__toggle — burger button, hidden on desktop (≥768px)
menu__overlay — backdrop, click to close. Dims to 50% black by default; override with --menu-backdrop
menu__drawer — side drawer, full width/height, slides from left
menu--open — added/removed by JS to toggle the drawer
⚠ Still in beta — API and class names may change before the stable release.
ℹ Depends on the menu component — load menu.js (and optionally scroll-lock.js) on the same page. menu-drawer uses df:menu:close to cascade-close open drawers, and the outer burger owns the body scroll lock while mobile nav is in drawer mode.
Turns a multi-level nav's top-level dropdowns into right-sliding drawers on tablet/mobile (≤991px). Built for Drupal-rendered mega-menus where markup cannot be changed — detects triggers purely by class structure (ul.menu.menu-level-0 > li with a direct-child .menu-dropdown-0). One drawer open at a time. Auto-injects a back button + title, wires full ARIA (role="dialog", aria-modal, aria-expanded, aria-controls), manages focus, and sizes the drawer to the viewport (with optional sticky-header offset). On desktop (≥992px) the same panels render as classic dropdowns — absolute-positioned under their parent. Click-to-toggle by default (clicking the trigger opens, clicking outside closes); add menu-drawer-hover to the root <ul> to reveal on hover or keyboard focus instead — click is then inert on desktop and the panel closes on mouse-out.
Tap an item to open its drawer — back button, title, and ESC key all close it. The demo is rendered at mobile width inside an iframe, so the drawer stays inside the frame.
The top-level header can be an <a> or a <span> — both work. Anything can live inside .menu-dropdown-0; the component doesn't care about the inner structure.
<ul class="menu menu-level-0">
<li class="menu-item menu-item--expanded">
<a href="#">Products</a>
<div class="menu-dropdown-0">
<!-- any markup: columns, image cards, sub-lists, etc. -->
<ul class="menu menu-level-1">
<li><a href="/a">Item A</a></li>
<li><a href="/b">Item B</a></li>
</ul>
</div>
</li>
<li class="menu-item menu-item--expanded">
<span>About</span>
<div class="menu-dropdown-0">
<ul class="menu menu-level-1">
<li><a href="/team">Team</a></li>
<li><a href="/careers">Careers</a></li>
</ul>
</div>
</li>
</ul>
CSS file + JS file:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/menu-drawer/menu-drawer.css">
<script src="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/menu-drawer/menu-drawer.js" defer></script>
Or via Sass:
@use 'dopamine-fluid/addons/components/menu-drawer/menu-drawer';
The drawer activates below 992px by default. Override both the SCSS variable and the matching JS global:
@use 'dopamine-fluid/addons/components/menu-drawer/menu-drawer' with ($menu-drawer-bp: 768px);
<script>window.DOPE_MENU_DRAWER_BP = 767;</script>
<script src="…/menu-drawer.js" defer></script>
If your page has a fixed/sticky header, set --menu-drawer-top so the drawer starts below it. The component reads this on open and sizes the drawer to fit the remaining viewport:
:root { --menu-drawer-top: 64px; }
Uses visualViewport.height under the hood so iOS URL-bar changes don't cut content off. The Drupal admin toolbar's --drupal-displace-offset-top is also respected automatically.
Add menu-dropdown-mega to any .menu-dropdown-0 and on desktop it switches to position: fixed spanning the full viewport width. Mobile drawer behavior is unchanged (still a right-slide drawer). Lay out the inside with dopamine grid / flex utilities — the component only sets the container.
<li class="menu-item menu-item--expanded">
<a href="#">Products</a>
<div class="menu-dropdown-0 menu-dropdown-mega">
<div class="grid cols-1 cols-md-4 gap-16-32 p-16-32">
<div>…column 1…</div>
<div>…column 2…</div>
…
</div>
</div>
</li>
Tell the component where your nav ends via --menu-mega-top so the dropdown sits flush below it (otherwise it defaults to the top of the viewport):
:root { --menu-mega-top: 64px; } /* height of your sticky nav */
If you'd rather ship your own back row (localized text, icon font, etc.), server-render a .menu-drawer-header as the first child of .menu-dropdown-0. The component skips injection when one already exists, and wires the first .menu-drawer-back-btn click to close.
The menu component ignores anything that isn't one of its own __ elements, so you can drop extra siblings (search box, language switcher, CTA, social icons) directly inside .menu__drawer alongside ul.menu.menu-level-0:
<div class="menu__drawer">
<button class="menu__close">×</button>
<div class="p-16 hidden-md">
<input type="search" class="df-input w-100" placeholder="Search…">
</div>
<ul class="menu menu-level-0">…</ul>
<a href="/login" class="block p-16 hidden-md">Log in</a>
</div>
Three things to know:
• A sub-drawer (.menu-dropdown-0 opening on mobile) is position: fixed at z-index: 1001 and covers the whole viewport — extra siblings are hidden while the user is drilled into a level-1 panel. Intentional, not a bug.
• Above $menu-bp the drawer becomes position: static and siblings render inline next to the nav. Use hidden-md (or whichever breakpoint) on items that shouldn't appear in the desktop navbar.
• Body scroll lock is held by the outer burger. Extras scroll inside the drawer via overflow-y: auto on .menu__drawer. Nothing else to wire.
menu menu-level-0 — root <ul>, the component scans for this selector
menu-item menu-item--expanded — top-level <li> (only those with a direct-child .menu-dropdown-0 become drawers)
menu-dropdown-0 — the panel that becomes the drawer on mobile
menu-drawer--open — added/removed by JS on the <li> to toggle the drawer (BEM-aligned with other component modifiers)
menu-drawer-header / menu-drawer-back-btn / menu-drawer-title — injected markup (or ship your own). Hidden on desktop.
menu-drawer-hover — add to the root <ul.menu.menu-level-0> to enable hover/focus reveal on desktop (click becomes inert; mobile drawer behavior unchanged)
menu-dropdown-mega — add to a .menu-dropdown-0 to make that specific dropdown span 100vw on desktop (no change on mobile)
data-menu-drawer-title="…" — on a trigger, overrides the auto-derived drawer title
--menu-drawer-top / --menu-drawer-height — CSS vars for sticky-header offset (mobile drawer) and JS-computed height
--menu-mega-top — CSS var for the top offset of mega dropdowns on desktop (typically your sticky nav's height)
Click a button to switch panels. Triggers use data-tab-target="#id" to point at a panel inside the same .tabs group. Requires a tiny JS file (~10 lines).
<div class="tabs">
<div class="tab__buttons">
<button data-tab-target="#p1" class="tab__btn tab__btn--active">One</button>
<button data-tab-target="#p2" class="tab__btn">Two</button>
</div>
<div class="tab__panels">
<div id="p1" class="tab__panel tab__panel--active">Panel one</div>
<div id="p2" class="tab__panel">Panel two</div>
</div>
</div>
CSS file + JS file:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/tabs/tabs.css">
<script src="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/tabs/tabs.js" defer></script>
Or via Sass:
@use 'dopamine-fluid/addons/components/tabs/tabs';
tabs — outer wrapper
tab__buttons — row that holds the triggers (style with your own utility classes, e.g. flex)
tab__panels — region that holds the panels
tab__btn — trigger button (give one tab__btn--active for the default)
tab__panel — content panel (give one tab__panel--active for the default)
data-tab-target="#id" — points the trigger at a panel ID inside the same .tabs
Click the toggle to open a menu. Click outside (anywhere not inside a dropdown menu) or press ESC to close all open dropdowns. Multiple dropdowns can be open at the same time. Requires a tiny JS file (~15 lines). Add dropdown-hover next to dropdown to also reveal on hover / keyboard focus (pointer devices only — touch stays click-only).
<div class="dropdown">
<button data-dropdown-toggle>Menu</button>
<ul class="dropdown__menu">
<li><a href="#">Item</a></li>
</ul>
</div>
CSS file + JS file:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/dropdown/dropdown.css">
<script src="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/dropdown/dropdown.js" defer></script>
Or via Sass:
@use 'dopamine-fluid/addons/components/dropdown/dropdown';
dropdown — wrapper, sets position: relative
dropdown__menu — absolutely positioned panel, hidden by default
data-dropdown-toggle — attribute on the trigger button
dropdown--open — added/removed by JS to toggle the menu
A generic show/hide primitive. A [data-collapse-target="#id"] button toggles any target element by ID. Ships two modes: default (inline, pushes siblings, animates height) and collapse--absolute (floats over content, fades). Requires a tiny JS file (~25 lines).
Next line — watch it slide down when the collapse above opens.
Wrap the trigger and target in a positioned ancestor so the absolute child anchors correctly.
Next line stays in place regardless of the popover state.
<!-- Inline mode (default) -->
<button data-collapse-target="#panel">Toggle</button>
<div id="panel" class="collapse">
<div class="collapse__content">
<!-- padded / styled content goes INSIDE collapse__content -->
<div class="p-16-32">Any content here</div>
</div>
</div>
<!-- Absolute mode — requires a positioned ancestor -->
<div class="relative inline-block">
<button data-collapse-target="#pop">Toggle</button>
<div id="pop" class="collapse collapse--absolute">
<div class="collapse__content">
<div class="p-16-32">Floating content</div>
</div>
</div>
</div>
<!-- Start open: add collapse--open -->
<div id="panel" class="collapse collapse--open">…</div>
CSS file + JS file:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/collapse/collapse.css">
<script src="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/collapse/collapse.js" defer></script>
Or via Sass:
@use 'dopamine-fluid/addons/components/collapse/collapse';
collapse — target element / grid container.
collapse__content — required direct child. Must not have padding of its own — padding on the grid item prevents the closed state from truly collapsing to 0. Put your padded / styled content inside it.
collapse--open — added/removed by JS; also usable as a default-open hint.
collapse--absolute — variant: floats over content with an opacity fade. Needs a positioned ancestor in the markup to anchor against.
data-collapse-target="#id" — attribute on the trigger button; points at the target by ID.
A tiny, cross-browser body scroll lock shared by modal and menu. Include this one file and those components will freeze the page behind them while open — on iOS Safari (where overflow: hidden alone doesn't work), on desktop Windows / Firefox (compensates for the scrollbar so the page doesn't jump), and everywhere else. Ref-counted, so multiple dialogs can lock independently without stomping each other. No CSS, no markup — pure JS helper.
menu locks only in drawer mode (it checks that .menu__drawer is position: fixed, so desktop inline nav never locks). menu-drawer does not lock on its own — the outer burger already holds the lock for the entire session, and the drawer opens on top of that.
<script src="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/scroll-lock/scroll-lock.js" defer></script>
Once loaded, modal and menu detect it automatically via window.dopamine.scrollLock and use it on open/close. If you don't include it, those components still work — just without the scroll lock.
dopamine.scrollLock.lock(); // freeze body scroll
dopamine.scrollLock.unlock(); // release (only unfreezes when the ref-count hits 0)
dopamine.scrollLock.isLocked(); // boolean
lock() / unlock() — ref-counted. Call unlock() once per lock(); the body is only released when the count returns to zero.
isLocked() — reports current state (count > 0).
Every component registers methods on window.dopamine and emits bubbling CustomEvents on its root element. Use the API to drive components from your code, and listen for events to react to state changes.
dopamine.modal.open('my-modal');
dopamine.modal.close('my-modal');
dopamine.tabs.activate('#panel-2');
dopamine.dropdown.closeAll();
dopamine.dropdown.toggle(document.querySelector('.dropdown'));
dopamine.menu.toggle(document.querySelector('.menu'));
dopamine.accordion.open(document.querySelector('.accordion__item'));
Events bubble, so you can delegate from document or scope listeners to a specific element.
document.addEventListener('df:modal:open', e => {
console.log('modal opened', e.target.id);
});
document.addEventListener('df:tabs:change', e => {
console.log('new panel', e.detail.panel.id);
console.log('trigger', e.detail.trigger);
});
document.querySelector('#my-dropdown')
.addEventListener('df:dropdown:close', () => {/* ... */});
| Component | Events | API |
|---|---|---|
accordion |
df:accordion:opendf:accordion:close |
open(el), close(el), toggle(el) |
modal |
df:modal:opendf:modal:close |
open(idOrEl), close(idOrEl), toggle(idOrEl) |
menu |
df:menu:opendf:menu:close |
open(el), close(el), toggle(el) |
menu-drawer |
df:menu-drawer:opendf:menu-drawer:close |
open(li), close(li), toggle(li), isOpen(li), closeAll(), refresh() |
tabs |
df:tabs:changedetail: { panel, trigger } |
activate(panelIdOrEl) |
dropdown |
df:dropdown:opendf:dropdown:close |
open(el), close(el), toggle(el), closeAll() |
collapse |
df:collapse:opendf:collapse:close |
open(idOrEl), close(idOrEl), toggle(idOrEl) |
Events fire immediately after the class is flipped — so :open fires as the opening transition is starting, and :close fires as the hiding transition is starting. That's the right moment for most work: updating state, logging, focusing an input, loading data.
If you need to wait for the animation to finish — for example, to unmount content only once a modal has fully faded out — listen for transitionend on the element that actually animates:
document.addEventListener('df:modal:close', e => {
// e.target is the .modal (the open class has already been removed).
// The .modal__dialog is what animates — wait for it to finish:
const dialog = e.target.querySelector('.modal__dialog');
dialog.addEventListener('transitionend', () => {
// fade-out done — safe to unmount content or free resources
}, { once: true });
});
Pick whichever element has the transition in its CSS:
.modal__dialog for modal — .menu__drawer for menu — .menu-dropdown-0 for menu-drawer — .dropdown__menu for dropdown — .accordion__body for accordion.
Tabs has no transition by default, so df:tabs:change already fires at the final state.
This modal uses modal__dialog--fade — it fades in and scales up slightly.
Click the overlay, the × button, or press ESC to close.