A lightweight, modern CSS framework for building responsive websites with elegance and speed. MaverickWave provides a clean foundation suitable for projects ranging from personal portfolios to corporate websites and e-commerce platforms.
While AI tools helped kickstart the development of some components and provided initial structure, approximately 80% of the codebase required manual refinement and customization. The framework has been meticulously crafted to ensure:
- Consistent implementation of custom variables throughout the system
- Proper integration between components and logical SCSS structure
- Optimized specificity and selector hierarchy
- Reliable responsive behavior across devices
- Built-in dark mode support and accessibility considerations
The result is a framework that balances utility with simplicity, offering developers a solid foundation that can be easily customized.
➡️ View the Live Showcase & Documentation
- Responsive Grid System
- 30+ UI Components: Buttons, Cards, Panels, Tabs, Accordions, Modals, Tiles, Alerts, Spinners, Progress Bars, Avatars, Tags, Badges, Dropdown, Ratings, Stepper, Skeleton Loader, Empty State, Price, Offer Cards, Divider, and more
- Form Elements: Input, Select, Textarea, Checkbox, Radio, Toggle, Input Group, with
mw-fieldwrapper pattern for Angular Reactive Forms - Utility Classes for spacing, flex, display, typography, text overflow, elevation and aspect ratio
- A five-step elevation ramp and a motion scale, so every shadow and every transition in the framework comes from one place
- Mobile as a first-class target: 44px touch targets on a coarse pointer, modals that become bottom sheets, press states on everything, and hover effects that do not latch after a tap
- Easy Customization via CSS Custom Properties
- Built-in Light & Dark Mode with optional theme switching
- SCSS Source Files for advanced customization (Dart Sass,
@use/@forward) - Minimal JavaScript footprint (single vanilla JS file, no dependencies)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My MaverickWave Project</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/maverick-wave@4.25.0/maverick-wave.min.css"
/>
</head>
<body>
<!-- Your content here -->
<script src="https://cdn.jsdelivr.net/npm/maverick-wave@4.25.0/maverick-wave.min.js"></script>
</body>
</html>Always pin to a specific version in production for stability.
Download maverick-wave.min.css and maverick-wave.min.js from the latest release and include them manually:
<link rel="stylesheet" href="path/to/maverick-wave.min.css" />
<script src="path/to/maverick-wave.min.js"></script>There are thirteen root color tokens. Everything else - hover tones, translucent
backgrounds, borders, muted text - is derived from them at runtime with
color-mix(), so overriding a root token is enough. Load your stylesheet
after maverick-wave.min.css:
:root {
/* brand */
--mw-primary-color: #0f766e;
--mw-secondary-color: #b45309;
/* status */
--mw-success-color: #15803d;
--mw-warning-color: #a16207;
--mw-danger-color: #b91c1c;
--mw-info-color: #0e7490;
/* neutrals and themes */
--mw-gray-color: #64748b;
--mw-dark-page-background: #172127;
--mw-dark-text-color: #e8eef0;
--mw-light-page-background: #f2f6f7;
--mw-light-text-color: #172127;
--mw-form-elements-background: #f5f9fa;
/* text on every solid colored surface: buttons, table and panel headers.
The one token that cannot be derived - a light brand color needs a dark
label, a dark one a light label. Set it opposite your --mw-primary-color. */
--mw-accent-text-color: #f2fafa;
/* per color override of that label, for a palette that does not sit on one
side of the lightness scale. Same token for secondary, success, warning,
danger and info. */
--mw-primary-accent-text-color: #0b0f0a;
--mw-font-family-base: 'Your Font Name', sans-serif;
}Setting --mw-primary-color alone also retunes --mw-primary-color-hover,
--mw-primary-background, --mw-primary-background-hover,
--mw-primary-text-color, --mw-border-accent and the whole --mw-header-*
set - the bar is the primary darkened toward black, so the chrome follows the
brand without a second value. The same holds for --mw-dark-page-background,
which drives the dark card, footer and border tones. Each derived token can
still be overridden individually if you want to break out of the scale.
The derivation needs
color-mix()and relative colour syntax (oklch(from ...)): Chrome 119+, Safari 16.4+, Firefox 128+. Older browsers get no colours at all, not merely worse ones. The same floor is set asbrowserslistinpackage.json, which is what Autoprefixer and cssnano read when buildingdist/.
Two scales that are not colours, and both are tokens for the same reason: a framework where every component picks its own shadow and its own timing looks assembled rather than designed.
:root {
/* Elevation. Each level is two shadows - a tight contact layer that gives
the box weight, and a wide ambient one that says how high it floats.
Declared per theme, because the tones they are mixed from differ. */
--mw-elevation-1: /* resting: inputs, tags, small controls */;
--mw-elevation-2: /* raised: cards and panels at rest */;
--mw-elevation-3: /* floating: a card under the pointer */;
--mw-elevation-4: /* overlay: dropdowns, popovers, drawer */;
--mw-elevation-5: /* modal */;
/* Motion */
--mw-duration-instant: 90ms; /* a press */
--mw-duration-fast: 150ms; /* a colour swap under the pointer */
--mw-duration-base: 240ms; /* the default */
--mw-duration-slow: 400ms; /* something crossing the screen */
--mw-duration-slower: 700ms; /* a slider, a progress bar */
--mw-ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* things arriving */
--mw-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* A to B and back */
--mw-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* a pop */
/* The two ready-made transitions every component uses */
--mw-transition: /* the explicit paint-only property list, base duration */;
--mw-transition-fast: /* the same list, fast duration */;
/* Control sizes - one height per step, shared by input, select, textarea
and button, so a field and the button beside it line up */
--mw-control-height-sm: 2rem;
--mw-control-height: 2.25rem;
--mw-control-height-lg: 2.5rem;
--mw-control-font-sm: 0.8rem;
--mw-control-font: 0.9rem;
--mw-control-font-lg: 1rem;
--mw-control-line-height: 1.35;
/* Focus */
--mw-focus-ring-width: 2px;
--mw-focus-ring-offset: 2px;
--mw-focus-ring-color: var(--mw-primary-text-color);
--mw-focus-halo-size: 3px; /* the soft ring a form field gets instead */
--mw-focus-halo-opacity: 28%;
}--mw-transition deliberately lists its properties rather than saying all: a
width that changes at a breakpoint should snap, not crawl. Anything that really
does want to animate a size says so itself. --mw-duration-zoom (650ms) is the
one for a large surface actually travelling - an image scaling inside a card, a
slider track crossing its frame - because the eye reads speed as distance over
time, and the 300ms that feels right on a 40px button feels snatched on a 300px
photo.
The control scale is what makes a form row line up. Each field used to work its own height out from its own font size, its own line-height and its own padding, and no two of them agreed: an input, a select and a button side by side measured 32.2, 34.4 and 37.2 pixels. They all measure the same now, and the fields share one font scale instead of the select sitting a step below the input next to it.
Under prefers-reduced-motion: reduce the duration tokens all drop to 1ms and a
blanket rule catches anything that names its own timing - including whatever you
wrote yourself. 1ms rather than 0, so a script waiting on transitionend still
gets one.
To retune from SCSS instead, the same values are $duration-*, $ease-*,
$control-height* / $control-font*, $focus-ring-* and $shadow-near-* /
$shadow-far-* in abstracts/_variables.scss, all !default.
Every brand and status colour comes in two tokens, and picking the right one is the whole trick:
--mw-primary-coloris the exact colour, for anything it fills - buttons, badges, bars, progress. The label on top is--mw-primary-accent-text-color.--mw-primary-text-coloris the same colour tuned to the active theme, for anything drawn on a theme surface - text, icons, focus rings, accent borders and dividers.
The second one exists because a colour picked to carry a label is by definition too dark or too light to be read on the page it sits on. The ink token is derived by clamping OKLch lightness and keeping hue and chroma:
--mw-dark-primary-text-color: oklch(
from var(--mw-primary-color) max(l, 0.68) c h
);
--mw-light-primary-text-color: oklch(
from var(--mw-primary-color) min(l, 0.55) c h
);That is a bound, not a target, which is what makes it work for any palette. A
dark brand colour gets lifted, a very light one gets deepened, and a colour
already inside the range passes through untouched - a neon green stays neon on
the dark page and only turns into a real green on the light one. The bounds are
$ink-lightness-dark and $ink-lightness-light in SCSS.
--mw-secondary-text-color and --mw-success/warning/danger/info-text-color
are derived the same way.
--mw-accent-text-color is the text on every solid coloured surface, and one
value for the whole palette only holds while all six colours sit on the same
side of the lightness scale. A neon green primary next to a deep blue secondary
needs a dark label on the one and a light label on the other, so every colour
carries its own override:
:root {
--mw-primary-color: #39ff14;
/* the shared label stays light for the rest of the palette ... */
--mw-accent-text-color: #f2f6fc;
/* ... only the neon primary gets a dark one */
--mw-primary-accent-text-color: #0b0f0a;
}--mw-secondary-accent-text-color and
--mw-success/warning/danger/info-accent-text-color work the same way, and each
defaults to --mw-accent-text-color, so nothing changes until you set one.
Everything that fills a surface with one of the six colours reads the matching
token: buttons and mini buttons, the burger button, table and panel headers,
card badges and ribbons, segmented and tab items, stepper dots, calendar
selection, timeline dates, accordion headers and progress labels. The burger
button switches to --mw-secondary-accent-text-color while the drawer is open,
because its surface does the same - --mw-header-burgerbutton-color and
--mw-header-burgerbutton-open-color override the two states individually.
--mw-text-muted-color is built the other way round: a true gray at a fixed
lightness with $muted-tint (12%) of the primary colour mixed in, so the gray
belongs to the palette without carrying its saturation. Stepping the theme's
text colour back instead would hand its tint straight through - a mint white
page would end up with mint green secondary text. Set $muted-tint: 0% for a
neutral gray.
Tinted surfaces follow one rule as well: --mw-*-background is 20% of the
colour, --mw-*-background-hover 45%. Both stay close enough to the surface
underneath that --mw-text-color keeps working on top, which is what makes an
alert, badge or tag readable in either theme.
Card, footer and border are derived from the page background by scaling its OKLch lightness and chroma by one factor, keeping the hue:
--mw-dark-card-background: oklch(
from var(--mw-dark-page-background) calc(l * 0.75) calc(c * 0.75) h
);Both themes step a card away from their text colour - down into the dark theme, up into the light one - so content always sits on the cleaner of the two surfaces. Chroma rides along with lightness because that is what a hand-picked stack does: a darker surface of the same hue carries less colour, not the same colour at a lower lightness.
The one thing to know when picking --mw-dark-page-background: it needs
headroom underneath. On a near-black page the surfaces below it have nowhere to
go and cards collapse into the background, leaving only the border to separate
them. The default sits at OKLch lightness 0.24 for that reason.
Each factor is a !default SCSS knob, so how far a card sits from the page is
one number per theme:
| Knob | Default | Effect |
|---|---|---|
$card-surface-dark |
0.85 |
Card in the dark theme - lower means a darker, more separated card |
$card-surface-light |
1.05 |
Card in the light theme - higher means a lighter card |
$footer-surface-dark |
0.75 |
Footer and header chrome, dark theme |
$footer-surface-light |
0.95 |
Footer and header chrome, light theme |
Below 1 steps toward black, above 1 toward white, so read 0.85 as "the card
sits at 85% of the page's lightness". Move both card knobs toward 1 for a flat,
borderless look; push them apart for cards that read as raised panels. The
footer knobs go further out than the card in the dark theme and the other way in
the light one, so the band under the page reads as chrome rather than as another
card - keep that ordering if you retune the card, or the footer stops looking
like a footer. The rule between the surfaces keeps its own fixed factor: it runs
against the card, or it disappears into what it separates.
For full control, clone the repository and integrate src/scss/main.scss into your Sass build (Dart Sass required). MaverickWave uses modern @use/@forward syntax. Pass your overrides through with:
// your styles.scss
@use 'path/to/maverick-wave/src/scss/main' with (
// Optional: choose theme mode ('switchable' | 'dark' | 'light')
$mw-theme-mode: 'switchable',
// Override root colors
$primary-color: #0f766e,
$secondary-color: #b45309,
$dark-background: #172127,
$light-background: #f2f6f7,
// Optional: the derivation knobs
$ink-lightness-dark: 0.68,
$ink-lightness-light: 0.55,
$muted-tint: 12%,
// Optional: how far card and footer sit from the page background
$card-surface-dark: 0.85,
$card-surface-light: 1.05,
$footer-surface-dark: 0.75,
$footer-surface-light: 0.95
);Plain variable assignments before
@usehave no effect - the framework declares its root colors with!default, which only@use ... withfeeds.
Every layer forwards one module per file and no @extend crosses a file
boundary, so components can be imported individually. Applications typically
have no use for the marketing components (blog-post, gallery,
content-slider, techstack-bucket, tiles, coming-soon, ratings,
home), and Angular bundle budgets notice the difference.
// styles.scss - configuration first, then pick
@use 'maverick-wave/src/scss/abstracts/variables' with (
$primary-color: #0f766e
);
@use 'maverick-wave/src/scss/base'; // required: :root tokens, reset, typography
@use 'maverick-wave/src/scss/layout/grid';
@use 'maverick-wave/src/scss/layout/page-header';
@use 'maverick-wave/src/scss/components/buttons';
@use 'maverick-wave/src/scss/components/cards';
@use 'maverick-wave/src/scss/components/modals';
@use 'maverick-wave/src/scss/components/tables';
@use 'maverick-wave/src/scss/components/tags';
@use 'maverick-wave/src/scss/form-elements';
@use 'maverick-wave/src/scss/utilities';A typical application subset like the one above compiles to roughly 85 kB raw / 14 kB gzipped, against 196 kB / 30 kB for the full build.
baseis not optional. It carries the:rootcustom properties - without it every component renders without colors. If you bring your own reset, use@use 'maverick-wave/src/scss/base/base'for the token block alone. Thewith (...)configuration has to be the first statement in the file, because every module loadsabstracts/variablesitself.
Reference the stylesheet in angular.json (or import the SCSS source as shown
above):
"styles": ["src/assets/maverick-wave.min.css", "src/styles.scss"]Do not add
maverick-wave.min.jsto a SPA. It wires everything up once onDOMContentLoadedand writes straight into the DOM: components rendered later are never initialized, and the theme toggle mutates the DOM behind Angular's change detection. The behaviors it covers (accordion, tabs, modal close, mobile nav, scroll spy, theme toggle, progress bars, sliders, alerts, galleries) are a few lines each in a component - the framework's state classes are the whole contract. Theme switching, for example, is a single class on<body>:
// theme.service.ts
document.body.classList.toggle('mw-theme-light', isLight);The mw-field wrapper groups label, control, hint and error. Bind the error
state yourself - the framework does not style Angular's ng-invalid /
ng-touched classes:
<div
class="mw-field"
[class.mw-field-has-error]="email.invalid && email.touched"
>
<label class="mw-field-label mw-required" for="email">Email</label>
<input id="email" type="email" class="mw-input" formControlName="email" />
@if (email.invalid && email.touched) {
<span class="mw-field-error">
<i class="fas fa-exclamation-circle"></i> Please enter a valid email
address.
</span>
}
</div>A complete usage guide for AI coding assistants - every component, token,
integration pattern and example - ships with the package as a Claude Code skill
in .claude/skills/mw-maverick-wave/.
- Node.js v18 or newer
- npm
- Git
git clone https://github.com/m1well/maverick-wave.git
cd maverick-wave
npm installnpm run start # Start Gulp watcher (recompiles on changes)
npm run showcase # Serve the built dist/ on http://localhost:8888
npm run build # Full clean build → dist/
npm run format # Format all source files with Prettier
npm run verify # Check dist/ for unused/undefined tokens and unknown classes
npm run release # Build non-minified release files to project rootnpm run verify runs against dist/, so build first. It fails when a token is
referenced but never defined, when a token is defined but never used, or when
the showcase or the docs use a mw-* class the CSS does not generate.
maverick-wave/
├── dist/ # Compiled output (CSS, JS, HTML showcase)
├── src/
│ ├── assets/ # Logos, favicons
│ ├── js/ # JavaScript source (single file)
│ ├── partials/ # HTML partials for the showcase
│ └── scss/
│ ├── abstracts/ # Variables, mixins, functions
│ ├── base/ # Reset, typography
│ ├── components/ # Component styles (one file per component)
│ ├── form-elements/ # Input, select, checkbox, toggle, etc.
│ ├── layout/ # Grid, container, header, footer
│ ├── utilities/ # Spacing, flex, display helpers
│ └── main.scss # SCSS entry point
├── .claude/
│ ├── commands/mw.md # Claude Code slash command (Angular quick reference)
│ └── skills/
│ └── maverick-wave/ # Claude Code skill: full usage guide + examples
├── scripts/verify.js # Class and token consistency check (npm run verify)
├── .prettierrc.json # Prettier configuration
├── gulpfile.js # Gulp tasks configuration
├── index.html # Showcase entry point
└── package.json
Contributions are welcome!
- Fork the repository.
- Create a new branch for your feature or fix.
- Make your changes and run
npm run format, thennpm run build && npm run verify. - Commit using Conventional Commits (e.g.,
feat: add new component). - Push and open a Pull Request.
MIT License
Created by m1well