Components

Pre-built structural CSS for common UI patterns. No colors, no sizing — just behavior. Style with Dopamine classes.

How Components Work

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

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.

What is Dopamine Fluid?
A CSS utility generator that creates fluid clamp() values and grid layouts from class names in your HTML. No runtime, no JavaScript — just scan your templates and generate pure CSS.
How does the Sass function work?
Import the Sass addon and use df.fluid(16, 48) anywhere in your Sass. Same clamp() math as the utility classes, for elements where you can't add class names.
Can I use it with Drupal?
Yes — scan your Twig templates, generate SCSS, and compile alongside your theme's existing Sass pipeline. Each component gets its own CSS file for Drupal's library system.
What browsers are supported?
All modern browsers. This accordion uses native <details>/<summary> elements — works in Chrome, Firefox, Safari, and Edge.

Minimum HTML

<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>

Include

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';

Add styling with Dopamine classes

<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>

Modal

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); }

Demo — Fade

Demo — Slide

Minimum HTML

<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>

Include

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';

Styled with Dopamine classes

<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)

Menu

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).

Minimum HTML

<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>

Include

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';

Custom breakpoint

The drawer switches to inline nav at 768px by default. Override via Sass:

@use 'dopamine-fluid/addons/components/menu/menu' with ($menu-bp: 992px);

Styled with Dopamine classes

<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

Menu Drawer Beta

⚠ 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.

Demo

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.

Minimum HTML

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>

Include

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';

Custom breakpoint

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>

Sticky-header offset

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.

Mega dropdown (100vw on desktop)

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 */

Custom back-button markup

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.

Extra content inside the burger drawer

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">&times;</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)

Tabs

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).

Demo

First panel content.
Second panel content.
Third panel content.

Minimum HTML

<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>

Include

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

Dropdown

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).

Demo

Minimum HTML

<div class="dropdown">
  <button data-dropdown-toggle>Menu</button>
  <ul class="dropdown__menu">
    <li><a href="#">Item</a></li>
  </ul>
</div>

Include

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

Collapse

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).

Demo — default (inline, animates height)

This panel expands and contracts in-flow, smoothly animating its height. Siblings below it get pushed down when it opens.

Next line — watch it slide down when the collapse above opens.

Demo — absolute (floats over content)

Wrap the trigger and target in a positioned ancestor so the absolute child anchors correctly.

I float over content. Siblings below me don't move.

Next line stays in place regardless of the popover state.

Minimum HTML

<!-- 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>

Include

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.

Scroll Lock

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.

Include

<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.

Using it yourself

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).

Events & JavaScript API

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.

Driving components programmatically

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'));

Listening for events

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', () => {/* ... */});

Reference

Component Events API
accordion df:accordion:open
df:accordion:close
open(el), close(el), toggle(el)
modal df:modal:open
df:modal:close
open(idOrEl), close(idOrEl), toggle(idOrEl)
menu df:menu:open
df:menu:close
open(el), close(el), toggle(el)
menu-drawer df:menu-drawer:open
df:menu-drawer:close
open(li), close(li), toggle(li), isOpen(li), closeAll(), refresh()
tabs df:tabs:change
detail: { panel, trigger }
activate(panelIdOrEl)
dropdown df:dropdown:open
df:dropdown:close
open(el), close(el), toggle(el), closeAll()
collapse df:collapse:open
df:collapse:close
open(idOrEl), close(idOrEl), toggle(idOrEl)

Event timing & transitions

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.

GitHub