npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dopamine-fluid

v0.7.0

Published

Dopamine Fluid — generate fluid CSS and grid layouts from class names

Readme

 ____   ___  ____   _    __  __ ___ _   _ _____   _____ _    _   _ ___ ____
|  _ \ / _ \|  _ \ / \  |  \/  |_ _| \ | | ____| |  ___| |  | | | |_ _|  _ \
| | | | | | | |_) / _ \ | |\/| || ||  \| |  _|   | |_  | |  | | | || || | | |
| |_| | |_| |  __/ ___ \| |  | || || |\  | |___  |  _| | |__| |_| || || |_| |
|____/ \___/|_| /_/   \_\_|  |_|___|_| \_|_____| |_|   |_____\___/|___|____/

Dopamine Fluid

The CSS that makes you feel good.

A Node.js CLI that scans your HTML/Twig templates and generates fluid CSS using clamp() — plus CSS Grid, flexbox, display, and layout utilities. All from class names. Zero runtime.

<div class="container-1200 p-16-48">
  <h1 class="fs-32-72 fw-bold mb-16-40">Hello World</h1>
  <div class="grid cols-1 cols-md-2 cols-lg-4 gap-16-32">
    <div class="p-12-32 radius-4-12">Card</div>
  </div>
</div>
dopamine ./templates --ext twig --out ./scss/_dopamine.scss

177 rules. 12KB. 20ms.

Documentation: https://panfotis.github.io/dopamine-fluid-css/


Features

  • Fluid by default — every value scales smoothly via clamp(), no media query spam
  • Class name = specfs-16-48 means font-size from 16px to 48px. Nothing to memorize
  • CSS Gridgrid cols-1 cols-md-1.3 cols-lg-4 with custom ratios via dot notation
  • Keyword utilities — display, flexbox, alignment, position, overflow, z-index
  • Breakpoint variants — any class + -md, -lg, -xl etc. for responsive behavior
  • Sass addondf.fluid() function for custom styles (optional, independent)
  • Components addon — pre-built structural CSS for accordion, modal, etc. (optional, independent)
  • Modern CSS reset — included automatically in every build
  • Only what you use — scans your files, generates only the classes found
  • Fast — 100 files in ~50ms, deduplicates across all files

Quick Start

npm install --save-dev dopamine-fluid sass concurrently browser-sync
npx dopamine-fluid init

dopamine init copies a starter templates/ folder, scss/ entrypoint, dopamine.config.json, and dopamine-safelist.txt into your project. If a package.json already exists, it also adds missing dopamine, sass, build, and dev scripts without overwriting your existing scripts.

Build the starter project:

npm run build         # if init added package.json scripts
npm run dev           # watch + BrowserSync live reload

# Or run the tools directly
npx dopamine
npx sass scss/main.scss:css/main.css scss/custom:css/custom --no-source-map

Starter Structure

The scaffolded project uses this structure. Files marked as generated appear after the first build:

your-project/
├── package.json                   # optional — if present, init adds missing scripts
├── dopamine.config.json           # scan/output configuration
├── dopamine-safelist.txt                   # optional extra classes to compile
├── templates/
│   └── index.html                 # starter markup
├── scss/
│   ├── _dopamine.scss             # generated — utility classes
│   ├── _dopamine-functions.scss   # generated — fluid() + breakpoint mixins
│   ├── main.scss                  # your SCSS entrypoint
│   └── custom/                    # optional extra SCSS files
└── css/
    ├── main.css                   # compiled output
    └── custom/                    # compiled custom styles

Starter Scripts

If package.json exists, dopamine init adds these scripts when they are missing:

| Command | What it does | |---------|-------------| | npm run dopamine | Only scan HTML → generate _dopamine.scss | | npm run sass | Only compile SCSS → CSS | | npm run build | Run dopamine + sass together | | npm run dev | Watch templates and SCSS, then live-reload with BrowserSync |

Direct Commands

If you do not want package.json scripts, run the tools directly:

npx dopamine
npx sass scss/main.scss:css/main.css scss/custom:css/custom --no-source-map

# Watch mode
npx dopamine --watch
npx sass scss/main.scss:css/main.css scss/custom:css/custom --no-source-map --watch

Repository Development

These scripts are for working on the dopamine-fluid repository itself, not for projects that install it from npm:

| Command | What it does | |---------|-------------| | npm run dev | Watch the repo demo templates + Sass + BrowserSync | | npm run dev:ddev | Same but proxies a DDEV URL for Drupal | | npm run dry | Preview generated CSS in terminal | | npm test | Run CLI/config regression tests | | npm run docs:build | Build the docs site | | npm run docs:dev | Watch the docs site |

Custom SCSS

Any .scss file in scss/custom/ (without a _ prefix) is compiled to its own .css file in css/custom/. Useful for Drupal libraries or page-specific styles.


CLI Usage

dopamine init [target]
dopamine [input] [options]

Commands

| Command | What it does | |---------|-------------| | dopamine init [target] | Copy the starter files into a project directory. Adds missing dopamine, sass, build, and dev scripts when package.json exists | | dopamine [input] [options] | Scan templates and generate CSS/SCSS |

Generate Options

| Flag | Description | Default | |------|-------------|---------| | input | File, directory, or glob to scan | . | | -c, --config <file> | Config file path | dopamine.config.json | | -o, --out <file> | Output file (.css or .scss) | scss/_dopamine.scss | | -w, --watch | Watch for changes and rebuild | — | | --ext <exts> | Extensions to scan (comma-separated) | twig,html,htm | | --no-header | Omit the generated header comment | — | | --no-reset | Omit the CSS reset | — | | --classes <file> | Path to a file with class names to compile (one per line) | — | | --manifest <file> | Emit list of compiled class names as JSON to <file> | — | | --dry-run | Print to stdout, don't write | — |

When --out ends in .scss, Dopamine also generates _dopamine-functions.scss alongside it.


Class Reference

How class names work

prefix-{value}                         → fixed (converted to rem)
prefix-{min}-{max}                     → fluid (clamp between viewports)
prefix-{bp}-{value}                    → fixed at breakpoint
prefix-{bp}-{min}-{max}               → fluid at breakpoint
prefix-{min}-{max}--{vpMin}-{vpMax}   → fluid with custom viewport

Value Prefixes

| Prefix | CSS Property | Fixed | Fluid (min-max) | Breakpoint | |--------|-------------|-------|-----------------|------------| | Typography | | | | | | fs | font-size | fs-16 | fs-16-48 | fs-md-16 / fs-md-16-48 | | fw | font-weight | fw-700 | — | — | | lh | line-height | lh-15 → 1.5 | — | lh-md-15 | | ls | letter-spacing | ls-5 → 0.05em | — | ls-md-10 | | Padding | | | | | | p | padding | p-16 | p-16-48 | p-md-16 / p-md-16-48 | | pt | padding-top | pt-16 | pt-16-48 | pt-md-16 / pt-md-16-48 | | pb | padding-bottom | pb-16 | pb-16-48 | pb-md-16 | | ps | padding-inline-start | ps-16 | ps-16-48 | ps-md-16 | | pe | padding-inline-end | pe-16 | pe-16-48 | pe-md-16 | | px | padding-left + right | px-16 | px-16-48 | px-md-16 / px-md-16-48 | | py | padding-top + bottom | py-16 | py-16-48 | py-md-16 / py-md-16-48 | | Margin | | | | | | m | margin | m-16 | m-16-48 | m-md-16 / m-md-16-48 | | mt | margin-top | mt-16 | mt-16-48 | mt-md-16 / mt-md-16-48 | | mb | margin-bottom | mb-16 | mb-16-48 | mb-md-16 / mb-md-16-48 | | ms | margin-inline-start | ms-16 | ms-16-48 | ms-md-16 | | me | margin-inline-end | me-16 | me-16-48 | me-md-16 | | mx | margin-left + right | mx-16 | mx-16-48 | mx-md-16 / mx-md-16-48 | | my | margin-top + bottom | my-16 | my-16-48 | my-md-16 / my-md-16-48 | | Margin Auto | | | | | | mx-auto | margin-left: auto + right: auto | — | — | mx-md-auto | | my-auto | margin-top: auto + bottom: auto | — | — | my-md-auto | | ms-auto | margin-inline-start: auto | — | — | ms-md-auto | | me-auto | margin-inline-end: auto | — | — | me-md-auto | | mt-auto | margin-top: auto | — | — | mt-md-auto | | mb-auto | margin-bottom: auto | — | — | mb-md-auto | | Sizing | | | | | | w | width | w-200 | w-200-600 | w-md-200 / w-md-200-600 | | h | height | h-100 | — | h-md-100 | | maxw | max-width | maxw-800 | maxw-400-800 | maxw-md-800 | | minw | min-width | minw-320 | minw-200-400 | minw-md-320 | | maxh | max-height | maxh-400 | — | maxh-md-400 | | minh | min-height | minh-200 | — | minh-md-200 | | Sizing Auto | | | | | | w-auto | width: auto | — | — | w-md-auto | | h-auto | height: auto | — | — | h-md-auto | | Gap | | | | | | gap | gap | gap-16 | gap-16-32 | gap-md-16 / gap-md-16-32 | | gapx | column-gap | gapx-16 | gapx-16-32 | gapx-md-16 | | gapy | row-gap | gapy-16 | gapy-16-32 | gapy-md-16 | | Other | | | | | | radius | border-radius | radius-8 | radius-4-16 | radius-md-8 / radius-md-4-16 | | cols | grid-template-columns | cols-3 / cols-1.3 | — | cols-md-3 / cols-md-1.3 | | span | grid-column | span-3span 3 | — | span-md-4 | | rowspan | grid-row | rowspan-2span 2 | — | rowspan-md-3 | | order | order | order-1 | — | order-md-2 / order-lg-4 | | grow | flex-grow | grow-0 / grow-1 | — | grow-md-2 | | shrink | flex-shrink | shrink-0 / shrink-1 | — | shrink-md-0 | | container | max-width + centered | container-1200 | — | — |

Notes:

  • ps / pe / ms / me emit logical properties (padding-inline-start, padding-inline-end, margin-inline-start, margin-inline-end). In LTR these behave identically to left/right; in RTL they automatically flip to the start/end of the reading direction.
  • Negative values via n prefix: mt-n10margin-top: -0.625rem, ls-n5letter-spacing: -0.05em, order-n1order: -1. Works with breakpoints (mt-md-n10) and fluid ranges where applicable (mt-n10-n5). Opt-in per prefix — only margins (m / mt / mb / ms / me / mx / my), ls, and order accept negatives. Others (fs, p*, w, h, lh, etc.) reject them with a clear warning.
  • fw is unitless — fw-700 outputs font-weight: 700, not rem. No fluid range.
  • order is unitless, fixed-only (no fluid range). Applies to flex and grid items. Positive integers only. Supports breakpoints: order-1, order-md-2, order-lg-4.
  • span / rowspan apply to grid children — use alongside cols-N on the parent. span-3 makes the item occupy 3 column tracks; rowspan-2 makes it span 2 rows. Supports breakpoints: span-md-4, rowspan-lg-3. Positive integers only (discrete grid lines); fluid ranges and negatives aren't meaningful here.
  • grow / shrink apply to flex childrengrow-1 makes an item fill available space, shrink-0 keeps an item from shrinking (useful for fixed sidebars). Unitless integers, fixed-only. Supports breakpoints: grow-md-2, shrink-md-0.
  • lh is unitless, fixed only (no fluid range). Values ≥ 10 are divided by 10: lh-151.5, lh-121.2. Values < 10 are whole numbers: lh-22. Supports breakpoints: lh-md-15
  • ls uses divisor 100 and emits emls-5letter-spacing: 0.05em (≈ Tailwind tracking-wider), ls-100.1em (≈ tracking-widest), ls-250.25em. Fixed-only; letter-spacing is idiomatically a per-breakpoint token, not a per-viewport one. Supports breakpoints: ls-md-8.
  • h, maxh, minh are fixed-only (no fluid ranges). Fluid clamp scales by viewport width, which produces wrong results on portrait/narrow viewports. Use viewport units for responsive heights: h-100dvh, minh-80svh, maxh-50vh
  • cols supports dot notation for ratios: cols-1.3 = 1fr 3fr, cols-1.2.1 = 1fr 2fr 1fr
  • container is standalone — any number works, containers can be nested
  • All pixel values are converted to rem (divided by 16) in the output
  • Viewport override syntax: fs-16-48--480-1920 uses 480px–1920px instead of config default
  • Unit suffixes (sizing prefixes only — w, h, maxw, minw, maxh, minh): append %, vw, vh, vmin, vmax, svw, svh, lvw, lvh, dvw, or dvh to emit that unit verbatim — no rem conversion. Examples: w-50%width: 50%, h-100dvhheight: 100dvh, minh-md-100svh@media(md+) { min-height: 100svh }. Fixed-only (no fluid ranges of units).

Keyword Classes

No value needed — each keyword maps to a single CSS declaration. All support breakpoint variants by appending -{bp}.

| Class | CSS Output | With breakpoint | |-------|-----------|----------------| | Display | | | | block | display: block | block-md | | inline | display: inline | inline-md | | inline-block | display: inline-block | inline-block-md | | flex | display: flex | flex-md | | inline-flex | display: inline-flex | inline-flex-md | | grid | display: grid | grid-md | | inline-grid | display: inline-grid | inline-grid-md | | hidden | display: none | hidden-md | | Flex Direction | | | | flex-row | flex-direction: row | flex-row-md | | flex-row-reverse | flex-direction: row-reverse | flex-row-reverse-md | | flex-col | flex-direction: column | flex-col-md | | flex-col-reverse | flex-direction: column-reverse | flex-col-reverse-md | | Flex Wrap | | | | flex-wrap | flex-wrap: wrap | flex-wrap-md | | flex-nowrap | flex-wrap: nowrap | flex-nowrap-md | | Justify Content | | | | justify-start | justify-content: flex-start | justify-start-md | | justify-center | justify-content: center | justify-center-md | | justify-end | justify-content: flex-end | justify-end-md | | justify-between | justify-content: space-between | justify-between-md | | justify-around | justify-content: space-around | justify-around-md | | justify-evenly | justify-content: space-evenly | justify-evenly-md | | Align Items | | | | align-start | align-items: flex-start | align-start-md | | align-center | align-items: center | align-center-md | | align-end | align-items: flex-end | align-end-md | | align-stretch | align-items: stretch | align-stretch-md | | align-baseline | align-items: baseline | align-baseline-md | | Align Self (child) | | | | self-start | align-self: flex-start | self-start-md | | self-center | align-self: center | self-center-md | | self-end | align-self: flex-end | self-end-md | | self-stretch | align-self: stretch | self-stretch-md | | self-auto | align-self: auto | self-auto-md | | Justify Items (grid) | | | | justify-items-start | justify-items: start | justify-items-start-md | | justify-items-center | justify-items: center | justify-items-center-md | | justify-items-end | justify-items: end | justify-items-end-md | | justify-items-stretch | justify-items: stretch | justify-items-stretch-md | | Justify Self (grid child) | | | | justify-self-start | justify-self: start | justify-self-start-md | | justify-self-center | justify-self: center | justify-self-center-md | | justify-self-end | justify-self: end | justify-self-end-md | | justify-self-stretch | justify-self: stretch | justify-self-stretch-md | | Place | | | | place-center | place-items: center | place-center-md | | Text Alignment | | | | text-left | text-align: left | text-left-md | | text-center | text-align: center | text-center-md | | text-right | text-align: right | text-right-md | | Font Weight (named) | | | | fw-light | font-weight: 300 | fw-light-md | | fw-normal | font-weight: 400 | fw-normal-md | | fw-medium | font-weight: 500 | fw-medium-md | | fw-bold | font-weight: 700 | fw-bold-md | | Position | | | | relative | position: relative | relative-md | | absolute | position: absolute | absolute-md | | fixed | position: fixed | fixed-md | | sticky | position: sticky | sticky-md | | Overflow | | | | overflow-hidden | overflow: hidden | overflow-hidden-md | | overflow-auto | overflow: auto | overflow-auto-md | | overflow-visible | overflow: visible | overflow-visible-md | | overflow-scroll | overflow: scroll | overflow-scroll-md | | Z-Index | | | | z-0 | z-index: 0 | z-0-md | | z-1 | z-index: 1 | z-1-md | | z-2 | z-index: 2 | z-2-md | | z-3 | z-index: 3 | z-3-md | | z-4 | z-index: 4 | z-4-md | | z-5 | z-index: 5 | z-5-md | | z-10 | z-index: 10 | z-10-md | | z-50 | z-index: 50 | z-50-md | | z-100 | z-index: 100 | z-100-md |


Grid System

Container

<div class="container-960">     <!-- max-width: 960px, centered -->
<div class="container-1200">    <!-- max-width: 1200px, centered -->
<div class="container-1920">    <!-- max-width: 1920px, centered -->

Any number works. Containers can be nested.

Columns — equal

<div class="grid cols-1 cols-md-2 cols-lg-4 gap-16-32">

Columns — custom ratios (dot notation)

<!-- Sidebar layout: 1fr + 3fr -->
<div class="grid cols-1 cols-md-1.3 gap-16-32">

<!-- Holy grail: 1fr + 3fr + 1fr -->
<div class="grid cols-1 cols-md-1.3.1 gap-16-32">

<!-- Mix equal and ratio -->
<div class="grid cols-1 cols-md-1.3 cols-lg-4 gap-16-32">

Gaps

<div class="grid cols-3 gap-16-32">              <!-- fluid gap -->
<div class="grid cols-2 gap-16">                 <!-- fixed gap -->
<div class="grid cols-2 gapx-32-48 gapy-4-8">   <!-- split axes -->

Keyword Classes

All keywords support breakpoint variants: append -sm, -md, -lg, -xl, -xxl.

Display

<div class="block">          <div class="inline">         <div class="inline-block">
<div class="flex">           <div class="inline-flex">    <div class="grid">
<div class="inline-grid">    <div class="hidden">

<!-- Responsive -->
<div class="hidden block-md">         <!-- hidden mobile, block at md -->
<div class="hidden grid-lg">          <!-- hidden mobile, grid at lg -->

Flexbox

<!-- Direction -->
<div class="flex flex-row">           <div class="flex flex-col">
<div class="flex flex-row-reverse">   <div class="flex flex-col-reverse">
<div class="flex flex-col flex-row-md">    <!-- stack mobile, row at md -->

<!-- Wrap -->
<div class="flex flex-wrap">          <div class="flex flex-nowrap">

<!-- Justify content -->
<div class="flex justify-start">      <div class="flex justify-center">
<div class="flex justify-end">        <div class="flex justify-between">
<div class="flex justify-around">     <div class="flex justify-evenly">

<!-- Align items -->
<div class="flex align-start">        <div class="flex align-center">
<div class="flex align-end">          <div class="flex align-stretch">
<div class="flex align-baseline">

Grid / Flex Alignment

<!-- Container -->
<div class="grid cols-3 align-center">              <!-- vertical -->
<div class="grid cols-3 justify-items-center">      <!-- horizontal -->
<div class="grid cols-3 place-center">              <!-- both axes -->

<!-- Child -->
<div class="self-start">      <div class="self-center">       <div class="self-end">
<div class="self-stretch">    <div class="self-auto">
<div class="justify-self-start">   <div class="justify-self-center">
<div class="justify-self-end">     <div class="justify-self-stretch">

Text Alignment

<p class="text-left">          <p class="text-center">         <p class="text-right">
<p class="text-center text-left-lg">     <!-- centered mobile, left at lg -->

Font Weight

<p class="fw-light">       <!-- 300 -->    <p class="fw-normal">      <!-- 400 -->
<p class="fw-medium">      <!-- 500 -->    <p class="fw-bold">        <!-- 700 -->
<p class="fw-900">         <!-- any numeric value -->

Position

<div class="relative">     <div class="absolute">
<div class="fixed">        <div class="sticky">     <div class="sticky-md">

Overflow

<div class="overflow-hidden">     <div class="overflow-auto">
<div class="overflow-scroll">     <div class="overflow-visible">

Z-Index

<div class="z-0">   <div class="z-1">   <div class="z-2">   <div class="z-3">
<div class="z-4">   <div class="z-5">   <div class="z-10">  <div class="z-50">  <div class="z-100">

Addons

Dopamine has two optional addons. Both are independent — use either, both, or neither.

Sass Addon — df.fluid()

For elements you can't add classes to (e.g. Drupal-rendered content). Import the function from addons/sass/:

@use 'dopamine-fluid/addons/sass/dopamine-functions' as df;

.node--article .field--body p {
  font-size: df.fluid(16, 48);
  margin-bottom: df.fluid(8, 24);
}

.hero-banner h1 {
  font-size: df.fluid(32, 96, 480, 1920);  // custom viewport
}

When you output to .scss, Dopamine also auto-generates a _dopamine-functions.scss with your config's viewport defaults.

Breakpoint Mixins

Both the standalone addon and the auto-generated functions file include breakpoint mixins that match your config:

@use 'dopamine-functions' as df;

.sidebar {
  display: none;
  @include df.breakpoint-up(lg) { display: block; }
}

.mobile-only {
  @include df.breakpoint-down(md) { display: block; }
}

Available: breakpoint-up($name) (min-width) and breakpoint-down($name) (max-width). Breakpoint names come from your dopamine.config.json.

Components Addon

Pre-built structural CSS for common UI patterns. No colors, no sizing — just behavior (transitions, open/close, visibility). Style with Dopamine classes in your HTML.

@use 'dopamine-fluid/addons/components/accordion/accordion';
@use 'dopamine-fluid/addons/components/menu/menu';
@use 'dopamine-fluid/addons/components/menu-drawer/menu-drawer';
@use 'dopamine-fluid/addons/components/tabs/tabs';
@use 'dopamine-fluid/addons/components/dropdown/dropdown';
@use 'dopamine-fluid/addons/components/collapse/collapse';
@use 'dopamine-fluid/addons/components/forms/checkbox';
@use 'dopamine-fluid/addons/components/forms/radio';
@use 'dopamine-fluid/addons/components/forms/switch';
@use 'dopamine-fluid/addons/components/forms/input';

Or pull all four form controls in with a single import:

@use 'dopamine-fluid/addons/components/forms/forms';   // bundles checkbox, radio, switch, input

The bundle also ships as a single css/components/forms/forms.css for non-Sass consumers — one <link> tag instead of four. Pick whichever fits: the bundle for "give me a working form", individual files for "I only need a switch".

<!-- Accordion — style with Dopamine classes -->
<details class="accordion__item radius-8 mb-8">
  <summary class="accordion__title p-12-24 fs-16-20 fw-bold">Question</summary>
  <div class="accordion__body">
    <div class="accordion__content p-12-24 fs-14-18">Answer</div>
  </div>
</details>

<!-- Menu — side drawer on mobile, inline on desktop -->
<nav class="menu">
  <button class="menu__toggle p-8 fs-24">&#9776;</button>
  <div class="menu__overlay"></div>
  <div class="menu__drawer p-24-48">
    <button class="menu__close p-8 fs-24">&times;</button>
    <a href="#">Home</a>
    <a href="#">About</a>
  </div>
</nav>

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

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

Menu Drawer (depends on menu — load menu.js on the same page) turns a multi-level nav's top-level dropdowns into right-sliding drawers on tablet/mobile (≤991px) and classic dropdowns on desktop. Triggers are detected by class structure — any ul.menu.menu-level-0 > li whose direct child is .menu-dropdown-0 becomes a drawer. One drawer open at a time; auto-injects a back button + title with full ARIA and focus management on mobile; desktop is click-to-toggle by default, click-outside closes. Add menu-drawer-hover to the root <ul> to also reveal on hover / keyboard focus.

<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><li><a href="/a">Item A</a></li></ul>
    </div>
  </li>
</ul>

Override the breakpoint (default 992px, component active below it):

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

Keep the JS in sync with the SCSS breakpoint by setting window.DOPE_MENU_DRAWER_BP = <bp - 1> before the script loads. For a sticky header, set --menu-drawer-top: 64px on :root and the drawer sizes itself to the remaining viewport (uses visualViewport.height to handle iOS URL-bar changes).

Mega dropdown (100vw on desktop) — add menu-dropdown-mega to any .menu-dropdown-0 and on desktop it becomes position: fixed spanning the full viewport width. Mobile is unchanged (still a right-slide drawer). Set --menu-mega-top on :root to your sticky nav's height so the dropdown sits flush below. Lay out the inside with dopamine grid/flex utilities — e.g. <div class="grid cols-1 cols-md-4 gap-16-32 p-16-32">.

Available components: accordion, modal, menu, menu-drawer, tabs, dropdown, collapse, checkbox, radio, switch, input.

Scroll Lock is a tiny shared helper (~30 lines, no CSS) that locks body scroll for modal and menu. Include it once and both components use it automatically. menu only locks while the drawer is actually in drawer mode (mobile) — desktop inline nav never triggers a lock. menu-drawer inherits the lock from its outer menu (the burger holds it for the whole session). Handles the iOS Safari overflow: hidden gap via position: fixed + scroll-position restore, and compensates for the desktop scrollbar so the page doesn't shift when it disappears. Ref-counted so nested dialogs don't unlock each other.

<script src="https://cdn.jsdelivr.net/npm/dopamine-fluid/dist/components/scroll-lock/scroll-lock.js" defer></script>

Drive it manually if you have your own dialog: dopamine.scrollLock.lock() / .unlock() / .isLocked(). Without this file loaded, modal and menu-drawer still work — they just don't lock body scroll.

Form components (checkbox, radio, switch) are pure-CSS styled replacements for native <input> checkboxes and radios. They keep the real <input> in the DOM (accessible + form-submittable), visually hide it, and render a styled sibling that reacts to :checked. No JS, no a11y tradeoffs. Markup contract:

<label class="df-checkbox">
  <input type="checkbox" class="df-checkbox__input">
  <span class="df-checkbox__box"></span>
  <span class="df-checkbox__label">Remember me</span>
</label>

Box/track sizes scale with the <label>'s font-size; borders and fill use currentColor, so you can size and colour the whole thing via Dopamine classes on the label.

For text entry, the input component provides a minimal .df-input class that applies to any text-like <input> (text, email, password, search, tel, url, number, date, time) and to <textarea>. It resets browser defaults and keeps everything at currentColor / inherit; apply p-*, radius-*, fs-* utilities on the same element to compose the look.

Using a component in your project

Three working paths, depending on your project's setup. All use accordion as the example — swap the component name (modal, menu, menu-drawer, tabs, dropdown, collapse, forms/checkbox, etc.) as needed.

1. CDN (jsDelivr / unpkg) — zero install, one tag per file:

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

For form controls, use the bundle (dist/components/forms/forms.css) for all four at once, or pin a single file (dist/components/forms/checkbox.css).

2. Copy from node_modules — for self-hosted setups (Drupal themes, static sites, anything that doesn't bundle JS). Add a one-liner to your project's package.json:

"scripts": {
  "vendor:dopamine": "mkdir -p public/vendor/dopamine && cp -R node_modules/dopamine-fluid/dist/components/* public/vendor/dopamine/"
}

Run it once on npm install (or wire it as a postinstall hook), then reference the local copies:

<link rel="stylesheet" href="/vendor/dopamine/accordion/accordion.css">
<script src="/vendor/dopamine/accordion/accordion.js" defer></script>

3. Sass + bundler — for projects with a build pipeline (webpack, vite, esbuild, parcel). Imports the source directly; your bundler/Sass compiles it into your own output:

// Your own SCSS file — compiled into your project's CSS bundle
@use 'dopamine-fluid/addons/components/accordion/accordion';
// Your JS entry — bundler resolves and includes the script
import 'dopamine-fluid/addons/components/accordion/accordion';

This path uses the addons/ source tree (not dist/), so changes to the source pull through your build automatically.

JavaScript API & events

Each component registers methods under window.dopamine and emits bubbling CustomEvents on its root element, so you can drive components programmatically and react to state changes.

// Drive components from your code
dopamine.modal.open('my-modal');
dopamine.tabs.activate('#panel-2');
dopamine.dropdown.closeAll();

// React to state changes (events bubble — delegate from document if you like)
document.addEventListener('df:modal:open', e => console.log('opened', e.target.id));
document.addEventListener('df:tabs:change', e => console.log('panel', e.detail.panel.id));

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

Collapse is a generic show/hide primitive with a data-collapse-target="#id" trigger. The target's direct child must be <div class="collapse__content"> (padding-free — your padded/styled content goes inside it, matching the accordion__content pattern). Default mode is in-flow with a height animation; add collapse--absolute for a floating fade (requires a positioned ancestor in the markup).

Event timing. Events fire immediately after the class is flipped — so :open fires when the opening transition is just starting, and :close fires when the hiding transition is just starting. That's the right moment for most work (updating state, logging, focusing an input).

If you need to wait for the transition to finish — e.g. 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 (class already removed).
  // The .modal__dialog is what animates — wait for it:
  const dialog = e.target.querySelector('.modal__dialog');
  dialog.addEventListener('transitionend', () => {
    // fade-out finished — safe to unmount / free resources
  }, { once: true });
});

Pick whichever element has the transition in its CSS: .modal__dialog for modals, .menu__drawer for menu, .dropdown__menu for dropdown, .accordion__body for accordion. Tabs has no transition by default, so df:tabs:change already fires at the final state.


Config

Create a dopamine.config.json in your project root:

{
  "input": "./templates",
  "ext": "html",
  "out": "./scss/_dopamine.scss",
  "classes": "./dopamine-safelist.txt",

  "viewport": {
    "min": 320,
    "max": 1440
  },
  "breakpoints": {
    "sm": 576,
    "md": 768,
    "lg": 992,
    "xl": 1200,
    "xxl": 1400
  },
  "prefixes": {
    "fs": {
      "vpMin": 375,
      "vpMax": 1440
    }
  }
}

Input / Output

| Key | Description | Default | |-----|-------------|---------| | input | File, directory, or glob to scan | . | | ext | File extensions to scan (comma-separated) | twig,html,htm | | out | Output file (.css or .scss) | scss/_dopamine.scss | | classes | Path to a classes file (one class per line) | — |

These can also be passed as CLI flags — CLI args override config values.

# Uses config values
dopamine

# CLI overrides
dopamine ./src --ext twig --out ./scss/_dopamine.scss

Viewport priority

1. Inline override      →  fs-16-48--480-1920
2. Per-prefix config    →  prefixes.fs.vpMin / vpMax
3. Global default       →  viewport.min / max

Breakpoint-prefixed classes (fs-md-24-48, p-lg-16-32, …) use the same clamp math as their base counterpart. The breakpoint only controls the @media (min-width: …) wrapper — it doesn't change the viewport range the clamp is computed over.

Custom breakpoints

Add any you need. Set to null to remove a default:

{
  "breakpoints": {
    "xxxl": 1600,
    "xxxxl": 1920,
    "xxl": null
  }
}

Class Audit

Scan your templates and find near-duplicate fluid classes that could be merged:

npx dopamine-audit

Uses the same input, ext values from dopamine.config.json if no args are passed.

Options

| Flag | Description | Default | |------|-------------|---------| | input | File, directory, or glob to scan | from config or . | | --ext <exts> | Extensions to scan | from config or twig,html,htm | | --prefix <list> | Only audit these prefixes (comma-separated) | all | | --close-min <px> | Max min-value delta for merge suggestions | 2 | | --close-max <px> | Max max-value delta for merge suggestions | 4 | | --include-breakpoints | Include breakpoint variants in suggestions | — | | --include-inline-vp | Include inline viewport overrides in suggestions | — | | -o, --out [file] | Write report to a file (plain text) | audit |

Example output

Dopamine Class Audit
─────────────────────────────────
Files scanned              1
Numeric classes (unique)   31
Fluid ranges               24
Fixed values               7

Range Inventory
- fs@base: fs-16-20(9), fs-18-24(2), fs-24-48(7), fs-32-84(3)
- mb@base: mb-8-16(12), mb-16-32(2), mb-24-48(2)

Close-Range Merge Suggestions
Threshold: Δmin <= 2px and Δmax <= 4px
- fs @ base: keep `fs-16-20` (9 uses)
  replace `fs-18-24` (2 uses, Δmin 2px, Δmax 4px)

Classes File

You can provide classes directly in a plain text file — one class per line. Useful for prototyping, generating a utility stylesheet from a curated list, or integrating with tools that output class lists.

Create a file (e.g. dopamine-safelist.txt):

# One class per line
fs-16-48
cols-3

Empty lines and lines starting with # are ignored.

Run via CLI:

npx dopamine --classes dopamine-safelist.txt

Or set it in dopamine.config.json:

{
  "classes": "./dopamine-safelist.txt"
}

Classes from the file are merged with any template-scanned classes. Unrecognized class names trigger a per-class diagnostic so you can spot typos or wrong syntax early — e.g. 'px' suffix isn't needed, unit suffix 'dvh' is only supported on sizing prefixes, breakpoint 'xxl' not found in config.breakpoints, 'h' doesn't support fluid ranges. In watch mode, the classes file is also watched for changes.

What the scanner picks up. Classes are extracted from three places in your templates:

  1. Literal class="..." / className="..." attributes, including ternary branches. class="{{ c ? 'fs-16' : 'fs-20' }}" captures both fs-16 and fs-20. Twig's one-sided shorthand works too: class="item {{ active ? 'is-active' }}".
  2. Twig {% set <var> = ... %} assignments — the RHS is scanned for string literals. Arrays, ternaries, or bare strings all work.
  3. addClass(...) calls — every string literal in the argument list is extracted. Covers addClass('foo bar'), addClass(['foo', 'bar']), and multi-arg forms like addClass(classes, '', cond ? 'foo').

Dynamic pieces that can't be known at build time still need the safelist:

  • Concatenation like 'block-' ~ slug — the literal 'block-' is seen but rejected as incomplete; the composed final names (block-foo, block-bar) go in the classes file.
  • Variable-only references like link(title, url, {'class': link_classes}) or removeClass(style_settings.width) — no literal to extract.
  • JS template literals like className={`foo-${x}`} — use the safelist.

Bare tokens in <code> blocks, comments, and prose are still ignored — only the three sources above feed the compiler.


Development: refreshing the golden snapshot

The test suite includes a golden-file integration test that compiles a comprehensive fixture and compares the generated SCSS, Sass functions file, and diagnostic output byte-for-byte against committed expected files. It catches any unintended change to compiled output across the full pipeline.

When you intentionally change the fixture or production code that affects output, refresh the expected files:

UPDATE_GOLDEN=1 npm test

Commit the code change and the refreshed fixtures (test/fixtures/golden.expected.*) together — reviewers can then see exactly what user-visible output changed. When adding a new prefix, unit, or syntax, add a representative class to test/fixtures/golden.html (or golden.classes.txt for diagnostics) so the new feature is under the safety net too.


Manifest

Emit a JSON list of every class Dopamine compiled. Useful for tooling that needs to know "what classes exist" without re-scanning templates — e.g. autocomplete in a CMS admin UI.

npx dopamine --manifest ./dopamine.manifest.json

Or via dopamine.config.json:

{ "manifest": "./dopamine.manifest.json" }

Output:

{
  "version": 2,
  "generated": "2026-04-15T09:40:18.818Z",
  "classes": [
    { "name": "cols-md-1.3", "count": 1 },
    { "name": "flex",        "count": 4 },
    { "name": "fs-16-48",    "count": 2 },
    { "name": "p-md-16-32",  "count": 1 }
  ]
}

The classes array is sorted alphabetically by name for deterministic diffs. Each entry includes count — the number of times the class was referenced across scanned templates (safelist-only classes get count: 0). The version field bumps when the shape changes in a breaking way.

writeManifest compares the new output against the existing file and skips the write when nothing changed — avoids touching the file's mtime and prevents downstream file watchers (e.g. a VS Code extension reading the manifest) from firing on no-op rebuilds.


Benchmarking

Measure how long a build takes at realistic project scale:

npm run bench -- --files 200 --classes-per-file 100
# or directly:
node scripts/bench.js --files 500 --classes-per-file 200 --runs 5
node scripts/bench.js --help

The script generates a synthetic project in a tmp directory, runs the CLI with DOPAMINE_TIMING=1, and reports per-phase medians (scan / parse / generate / manifest) over multiple runs — twice per invocation, once with --manifest and once without, so you can see the manifest phase's contribution directly.

Phase timings are also available on any normal build:

DOPAMINE_TIMING=1 npx dopamine --manifest ./dopamine.manifest.json

Prints a Timing (ms) block after the usual build summary. Zero cost when the env var is unset.

Reference measurement (200 files × 100 classes, WSL2): full build ~20 ms, manifest write ~7 ms, scan ~11 ms. Watch-mode rebuilds at this scale are perceptually instant.


CSS Reset

Every build includes a modern CSS reset by default. Disable with --no-reset or "reset": false in config:

*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
body { min-height: 100vh; line-height: 1.5; -webkit-font-smoothing: antialiased; }
img, picture, video, canvas, svg { display: block; max-width: 100%; }
input, button, textarea, select { font: inherit; }
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }

Using with Drupal

Setup in your theme

cd /path/to/your/drupal-theme
dopamine ./templates --ext twig --out ./scss/_dopamine.scss
sass scss/main.scss:css/main.css

Libraries

# mytheme.libraries.yml
global-styling:
  css:
    theme:
      css/main.css: {}

Gulp integration

const { run } = require('dopamine-fluid/lib/runner');

function buildDopamine() {
  return run('./templates', {
    config: 'dopamine.config.json',
    out: './scss/_dopamine.scss',
    ext: 'twig',
  });
}

exports.dopamine = buildDopamine;

Generated Output

/* ================================================
 * Generated by Dopamine Fluid
 *
 * Default viewport : 320px – 1440px
 * Breakpoints      : sm: 576px, md: 768px, lg: 992px
 * Rules            : 8
 * ============================================== */

/* Reset */

*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
body { min-height: 100vh; line-height: 1.5; -webkit-font-smoothing: antialiased; }
img, picture, video, canvas, svg { display: block; max-width: 100%; }
input, button, textarea, select { font: inherit; }
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }

/* Grid */

.container-1200 {
  width: 100%;
  max-width: 1200px;
  margin-left: auto;
  margin-right: auto;
}

.grid {
  display: grid;
}

/* Base */

.cols-1 {
  grid-template-columns: repeat(1, 1fr);
}

.fs-16-48 {
  font-size: clamp(1rem, calc(2.8571vw + 0.4286rem), 3rem);
}

.gap-16-32 {
  gap: clamp(1rem, calc(1.4286vw + 0.7143rem), 2rem);
}

/* md — min-width: 768px */

@media (min-width: 768px) {
  .cols-md-1\.3 {
    grid-template-columns: 1fr 3fr;
  }
}

License

MIT