css-is-awesome 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/scss/components/_navigation.scss +137 -1
- package/scss/recipes/mobile-nav.md +231 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [1.3.0](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.2.0...v1.3.0) (2026-09-04)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **nav:** cia ships mobile navigation - hamburger + drawer, zero JS ([d58f8f1](https://github.com/Jerry2d3d/css-is-awesome/commit/d58f8f15d877b974b978e5b2b3d35122e3f0da0f))
|
|
7
|
+
|
|
1
8
|
# [1.2.0](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.1.1...v1.2.0) (2026-09-04)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "css-is-awesome",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "A token-driven SCSS design system with light/dark theming, semantic color tokens, and a 800+ LOC mixin API.",
|
|
5
5
|
"homepage": "https://github.com/Jerry2d3d/css-is-awesome#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
|
-
// NAVIGATION — Navbar, Nav, Breadcrumb, Tabs, Pagination
|
|
2
|
+
// NAVIGATION — Navbar, Nav, Breadcrumb, Tabs, Pagination, Hamburger, Drawer
|
|
3
3
|
// ============================================================================
|
|
4
4
|
@use 'sass:map';
|
|
5
5
|
@use '../theme' as t;
|
|
@@ -184,3 +184,139 @@
|
|
|
184
184
|
|
|
185
185
|
&:disabled, &[disabled] { @include m.disabled; }
|
|
186
186
|
}
|
|
187
|
+
|
|
188
|
+
// ----------------------------------------------------------------------------
|
|
189
|
+
// HAMBURGER — three-bar menu icon button that morphs into an X when open.
|
|
190
|
+
// ----------------------------------------------------------------------------
|
|
191
|
+
// Zero JavaScript. Pair with a popover drawer: the browser manages
|
|
192
|
+
// aria-expanded on any <button popovertarget="..."> invoker, and the morph
|
|
193
|
+
// keys off that attribute (semantic state lives in ARIA, never a class).
|
|
194
|
+
//
|
|
195
|
+
// Usage (mixin — the primary API):
|
|
196
|
+
// .menu-button { @include hamburger; }
|
|
197
|
+
// <button class="menu-button" popovertarget="site-menu" aria-label="Menu">
|
|
198
|
+
// <span></span><span></span><span></span>
|
|
199
|
+
// </button>
|
|
200
|
+
//
|
|
201
|
+
// Checkbox fallback (no popover): apply the open-state morph yourself —
|
|
202
|
+
// .menu-check:checked + .menu-button { @include hamburger-open; }
|
|
203
|
+
//
|
|
204
|
+
// A11y: a real <button>, so focus/Enter/Space are native. Tap target
|
|
205
|
+
// defaults to 44px (WCAG 2.5.8 AAA; the 24px minimum is SC-level).
|
|
206
|
+
|
|
207
|
+
@mixin hamburger-open {
|
|
208
|
+
> span:nth-child(1) { translate: 0 var(--cia-bar-shift); rotate: 45deg; }
|
|
209
|
+
> span:nth-child(2) { opacity: 0; }
|
|
210
|
+
> span:nth-child(3) { translate: 0 calc(var(--cia-bar-shift) * -1); rotate: -45deg; }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@mixin hamburger(
|
|
214
|
+
$color: text-primary,
|
|
215
|
+
$bar-width: 1.375rem,
|
|
216
|
+
$bar-height: 2px,
|
|
217
|
+
$gap: 5px,
|
|
218
|
+
$target: 44px
|
|
219
|
+
) {
|
|
220
|
+
@include m.button-reset;
|
|
221
|
+
// Distance each outer bar travels to meet the middle one for the X.
|
|
222
|
+
--cia-bar-shift: calc(#{$bar-height} + #{$gap});
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
display: inline-flex;
|
|
225
|
+
flex-direction: column;
|
|
226
|
+
align-items: center;
|
|
227
|
+
justify-content: center;
|
|
228
|
+
gap: $gap;
|
|
229
|
+
min-width: var(--touch-target-min, #{$target});
|
|
230
|
+
min-height: var(--touch-target-min, #{$target});
|
|
231
|
+
border-radius: m.radius(sm);
|
|
232
|
+
// button-reset strips the outline; a keyboard user must still see focus.
|
|
233
|
+
@include m.focus-ring;
|
|
234
|
+
|
|
235
|
+
> span {
|
|
236
|
+
display: block;
|
|
237
|
+
width: $bar-width;
|
|
238
|
+
height: $bar-height;
|
|
239
|
+
border-radius: m.radius(full);
|
|
240
|
+
background: m.color($color);
|
|
241
|
+
@include m.transition(translate, rotate, opacity);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
&[aria-expanded="true"] { @include hamburger-open; }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ----------------------------------------------------------------------------
|
|
248
|
+
// DRAWER — slide-in navigation panel on the native Popover API.
|
|
249
|
+
// ----------------------------------------------------------------------------
|
|
250
|
+
// Zero JavaScript. The element carries the `popover` attribute; any
|
|
251
|
+
// <button popovertarget="..."> opens it. The browser gives light-dismiss
|
|
252
|
+
// (tap outside), Esc-to-close, top-layer stacking, and focus handling —
|
|
253
|
+
// no script tag, no hydration. Popover is Baseline 2024 (Chrome 114,
|
|
254
|
+
// Firefox 125, Safari 17). The slide-in animation rides @starting-style +
|
|
255
|
+
// allow-discrete and degrades to an instant open on older engines.
|
|
256
|
+
//
|
|
257
|
+
// Usage (mixin — the primary API):
|
|
258
|
+
// .site-drawer { @include drawer; } // slides from end
|
|
259
|
+
// .site-drawer { @include drawer($side: start); } // nav sidebar
|
|
260
|
+
// .site-drawer { @include drawer($side: top, $size: auto); } // menu sheet
|
|
261
|
+
//
|
|
262
|
+
// <nav id="site-menu" class="site-drawer" popover aria-label="Site menu">
|
|
263
|
+
// ...links...
|
|
264
|
+
// </nav>
|
|
265
|
+
//
|
|
266
|
+
// $side is logical (start/end follow reading direction), so RTL works free.
|
|
267
|
+
|
|
268
|
+
@mixin drawer(
|
|
269
|
+
$side: end, // start | end | top | bottom
|
|
270
|
+
$size: 20rem, // inline-size (start/end) or block-size (top/bottom)
|
|
271
|
+
$bg: surface-default,
|
|
272
|
+
$backdrop: rgba(0, 0, 0, 0.4),
|
|
273
|
+
$p: 5,
|
|
274
|
+
$shadow: lg
|
|
275
|
+
) {
|
|
276
|
+
position: fixed;
|
|
277
|
+
margin: 0;
|
|
278
|
+
border: 0;
|
|
279
|
+
padding: m.space($p);
|
|
280
|
+
background: m.color($bg);
|
|
281
|
+
color: m.color(text-primary);
|
|
282
|
+
box-shadow: m.shadow($shadow);
|
|
283
|
+
overscroll-behavior: contain;
|
|
284
|
+
overflow: auto;
|
|
285
|
+
|
|
286
|
+
@if $side == start or $side == end {
|
|
287
|
+
inset-block: 0;
|
|
288
|
+
block-size: 100%;
|
|
289
|
+
inline-size: min(#{$size}, 85vw);
|
|
290
|
+
@if $side == start { inset-inline: 0 auto; --cia-drawer-slide: -100% 0; } @else { inset-inline: auto 0; --cia-drawer-slide: 100% 0; }
|
|
291
|
+
} @else {
|
|
292
|
+
inset-inline: 0;
|
|
293
|
+
inline-size: 100%;
|
|
294
|
+
block-size: $size;
|
|
295
|
+
max-block-size: 85dvh;
|
|
296
|
+
@if $side == top { inset-block: 0 auto; --cia-drawer-slide: 0 -100%; } @else { inset-block: auto 0; --cia-drawer-slide: 0 100%; }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Closed state (display:none via popover) exits with a slide; entry
|
|
300
|
+
// animates from @starting-style. allow-discrete lets `display` wait for
|
|
301
|
+
// the transition — engines without it simply open/close instantly.
|
|
302
|
+
translate: var(--cia-drawer-slide);
|
|
303
|
+
transition:
|
|
304
|
+
translate 240ms ease,
|
|
305
|
+
display 240ms ease allow-discrete,
|
|
306
|
+
overlay 240ms ease allow-discrete;
|
|
307
|
+
|
|
308
|
+
&:popover-open {
|
|
309
|
+
translate: 0 0;
|
|
310
|
+
@starting-style {
|
|
311
|
+
translate: var(--cia-drawer-slide);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
&::backdrop {
|
|
316
|
+
background: $backdrop;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
@media (prefers-reduced-motion: reduce) {
|
|
320
|
+
transition: none;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mobile-nav
|
|
3
|
+
description: Hamburger button + slide-in drawer for mobile navigation — zero JavaScript, built on the native Popover API.
|
|
4
|
+
category: navigation
|
|
5
|
+
complexity: simple
|
|
6
|
+
cia-version: ">=1.3.0"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Use this when
|
|
10
|
+
|
|
11
|
+
Your top navigation has more links than a phone screen can hold and you want
|
|
12
|
+
the classic hamburger-opens-a-drawer pattern without shipping any JavaScript.
|
|
13
|
+
This recipe is for **site/app navigation**; for content that overlays mid-page
|
|
14
|
+
(confirmations, forms), use the `dialog` recipe instead.
|
|
15
|
+
|
|
16
|
+
## Structure (raw HTML)
|
|
17
|
+
|
|
18
|
+
Two pieces: a real `<button>` that points at the drawer via `popovertarget`,
|
|
19
|
+
and the drawer itself — any element carrying the `popover` attribute.
|
|
20
|
+
|
|
21
|
+
```html
|
|
22
|
+
<header class="site-bar" data-cia-recipe="mobile-nav">
|
|
23
|
+
<a href="/" data-slot="brand">MySite</a>
|
|
24
|
+
|
|
25
|
+
<!-- Only visible at mobile widths (see Styling). The browser keeps
|
|
26
|
+
aria-expanded on this button in sync with the popover — for free. -->
|
|
27
|
+
<button class="menu-button" popovertarget="site-menu" aria-label="Menu">
|
|
28
|
+
<span></span><span></span><span></span>
|
|
29
|
+
</button>
|
|
30
|
+
|
|
31
|
+
<!-- Desktop inline nav AND the drawer content are the same list. -->
|
|
32
|
+
<nav id="site-menu" class="site-menu" popover aria-label="Site menu">
|
|
33
|
+
<a href="/">Home</a>
|
|
34
|
+
<a href="/about">About</a>
|
|
35
|
+
<a href="/services">Services</a>
|
|
36
|
+
<a href="/contact">Contact</a>
|
|
37
|
+
</nav>
|
|
38
|
+
</header>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Styling (cia mixins)
|
|
42
|
+
|
|
43
|
+
```scss
|
|
44
|
+
// SiteBar.module.scss — component stylesheet, so import the zero-emit barrel.
|
|
45
|
+
@use 'css-is-awesome/api' as cia;
|
|
46
|
+
|
|
47
|
+
.site-bar {
|
|
48
|
+
@include cia.navbar-base;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.menu-button {
|
|
52
|
+
display: none; // desktop: the inline nav is visible, no burger
|
|
53
|
+
|
|
54
|
+
@include cia.media-down(md) {
|
|
55
|
+
@include cia.hamburger; // three bars; morphs to an X while expanded
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.site-menu {
|
|
60
|
+
// Desktop: a plain inline nav. `popover` makes it display:none by
|
|
61
|
+
// default, so opt it back in above the breakpoint.
|
|
62
|
+
@include cia.media(md) {
|
|
63
|
+
display: flex;
|
|
64
|
+
gap: cia.space(5);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Mobile: the same element becomes a slide-in drawer.
|
|
68
|
+
@include cia.media-down(md) {
|
|
69
|
+
@include cia.drawer($side: end, $size: 18rem);
|
|
70
|
+
display: flex;
|
|
71
|
+
flex-direction: column;
|
|
72
|
+
gap: cia.space(2);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Pick the drawer's edge with `$side` (`start`/`end`/`top`/`bottom` — logical,
|
|
78
|
+
so RTL pages mirror automatically). `$side: top, $size: auto` gives the
|
|
79
|
+
drop-down-sheet look instead of a side panel.
|
|
80
|
+
|
|
81
|
+
## Interactivity
|
|
82
|
+
|
|
83
|
+
**There is zero JavaScript, and nothing to wire up.** The Popover API does
|
|
84
|
+
all of it natively:
|
|
85
|
+
|
|
86
|
+
- Tapping the button opens/closes the drawer (`popovertarget` toggles).
|
|
87
|
+
- The browser maintains `aria-expanded` on the button — which is exactly what
|
|
88
|
+
`cia.hamburger` keys the bars-to-X morph off.
|
|
89
|
+
- **Esc closes. Tapping outside closes** (light dismiss). The drawer sits in
|
|
90
|
+
the top layer above everything, no z-index management.
|
|
91
|
+
- The slide-in animation runs on `@starting-style` + `allow-discrete`
|
|
92
|
+
transitions; engines that predate those simply open instantly.
|
|
93
|
+
|
|
94
|
+
Popover is Baseline 2024 (Chrome 114, Firefox 125, Safari 17). If you must
|
|
95
|
+
support older engines, see the checkbox variant below.
|
|
96
|
+
|
|
97
|
+
## A11y checklist
|
|
98
|
+
|
|
99
|
+
- [ ] The trigger is a real `<button>` with an accessible name
|
|
100
|
+
(`aria-label="Menu"` or visible text) — never a bare `<label>` or `<div>`
|
|
101
|
+
([WCAG 4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html))
|
|
102
|
+
- [ ] `aria-expanded` reflects open state — the browser does this for
|
|
103
|
+
`popovertarget` invokers; verify with an inspector
|
|
104
|
+
([WAI-ARIA APG: Disclosure pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/))
|
|
105
|
+
- [ ] The drawer element is a `<nav>` with `aria-label` so it's discoverable
|
|
106
|
+
as a landmark
|
|
107
|
+
([WAI-ARIA APG: Landmark regions](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/))
|
|
108
|
+
- [ ] Esc closes the drawer and focus returns to the button (native popover
|
|
109
|
+
behavior — verify if you've added scripts elsewhere)
|
|
110
|
+
([WAI-ARIA APG: Disclosure navigation example](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/))
|
|
111
|
+
- [ ] Focus is visible on the button (`cia.hamburger` routes `focus-ring`)
|
|
112
|
+
and on every link inside the drawer
|
|
113
|
+
([WCAG 2.4.7 Focus Visible](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html))
|
|
114
|
+
- [ ] Tap target ≥ 24px minimum; `cia.hamburger` defaults to 44px
|
|
115
|
+
([WCAG 2.5.8 Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html))
|
|
116
|
+
- [ ] Reduced motion honored — both mixins disable their transitions under
|
|
117
|
+
`prefers-reduced-motion`, baked in
|
|
118
|
+
([WCAG 2.3.3 Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html))
|
|
119
|
+
|
|
120
|
+
## Framework examples
|
|
121
|
+
|
|
122
|
+
### React
|
|
123
|
+
|
|
124
|
+
```jsx
|
|
125
|
+
export function SiteBar() {
|
|
126
|
+
return (
|
|
127
|
+
<header className={styles.siteBar}>
|
|
128
|
+
<a href="/">MySite</a>
|
|
129
|
+
<button className={styles.menuButton} popoverTarget="site-menu" aria-label="Menu">
|
|
130
|
+
<span /><span /><span />
|
|
131
|
+
</button>
|
|
132
|
+
<nav id="site-menu" className={styles.siteMenu} popover="auto" aria-label="Site menu">
|
|
133
|
+
<a href="/">Home</a>
|
|
134
|
+
<a href="/about">About</a>
|
|
135
|
+
</nav>
|
|
136
|
+
</header>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
// React 19 forwards `popover` / `popoverTarget` as attributes — still no JS of yours.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Vue
|
|
143
|
+
|
|
144
|
+
```vue
|
|
145
|
+
<template>
|
|
146
|
+
<header class="site-bar">
|
|
147
|
+
<a href="/">MySite</a>
|
|
148
|
+
<button class="menu-button" popovertarget="site-menu" aria-label="Menu">
|
|
149
|
+
<span></span><span></span><span></span>
|
|
150
|
+
</button>
|
|
151
|
+
<nav id="site-menu" class="site-menu" popover aria-label="Site menu">
|
|
152
|
+
<a href="/">Home</a>
|
|
153
|
+
<a href="/about">About</a>
|
|
154
|
+
</nav>
|
|
155
|
+
</header>
|
|
156
|
+
</template>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Svelte
|
|
160
|
+
|
|
161
|
+
```svelte
|
|
162
|
+
<header class="site-bar">
|
|
163
|
+
<a href="/">MySite</a>
|
|
164
|
+
<button class="menu-button" popovertarget="site-menu" aria-label="Menu">
|
|
165
|
+
<span></span><span></span><span></span>
|
|
166
|
+
</button>
|
|
167
|
+
<nav id="site-menu" class="site-menu" popover aria-label="Site menu">
|
|
168
|
+
<a href="/">Home</a>
|
|
169
|
+
<a href="/about">About</a>
|
|
170
|
+
</nav>
|
|
171
|
+
</header>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Vanilla (Web Component)
|
|
175
|
+
|
|
176
|
+
```html
|
|
177
|
+
<!-- No component needed — the pattern IS vanilla HTML. Drop the Structure
|
|
178
|
+
markup in as-is; the popover attributes are the whole mechanism. -->
|
|
179
|
+
<script type="module">
|
|
180
|
+
// Intentionally empty. If you later want to close the drawer on in-page
|
|
181
|
+
// link clicks (same-page anchors don't navigate), this one line is the
|
|
182
|
+
// only JS the pattern can ever need:
|
|
183
|
+
// document.querySelectorAll('#site-menu a').forEach(a =>
|
|
184
|
+
// a.addEventListener('click', () => document.getElementById('site-menu').hidePopover()));
|
|
185
|
+
</script>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Variants
|
|
189
|
+
|
|
190
|
+
**Checkbox fallback (pre-popover engines).** The classic hidden-checkbox hack
|
|
191
|
+
— works everywhere, at the cost of weaker semantics (a `<label>` isn't a
|
|
192
|
+
button, there's no Esc-close, and you must manage `aria-expanded` yourself or
|
|
193
|
+
accept its absence):
|
|
194
|
+
|
|
195
|
+
```html
|
|
196
|
+
<input type="checkbox" id="menu-toggle" class="menu-check" aria-hidden="true">
|
|
197
|
+
<label for="menu-toggle" class="menu-button" aria-label="Menu">
|
|
198
|
+
<span></span><span></span><span></span>
|
|
199
|
+
</label>
|
|
200
|
+
<nav class="site-menu" aria-label="Site menu">…</nav>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```scss
|
|
204
|
+
.menu-check { display: none; }
|
|
205
|
+
.menu-button { @include cia.hamburger; }
|
|
206
|
+
.menu-check:checked + .menu-button { @include cia.hamburger-open; }
|
|
207
|
+
.site-menu { display: none; }
|
|
208
|
+
.menu-check:checked ~ .site-menu { display: flex; }
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`cia.hamburger-open` exists exactly for this: it is the bars-to-X morph as a
|
|
212
|
+
standalone mixin, so state can come from `:checked` instead of
|
|
213
|
+
`[aria-expanded]`.
|
|
214
|
+
|
|
215
|
+
## Pitfalls
|
|
216
|
+
|
|
217
|
+
- **`popover` hides the element everywhere**, including desktop — that's why
|
|
218
|
+
the Styling section explicitly restores `display: flex` above the
|
|
219
|
+
breakpoint. Forgetting this makes the desktop nav vanish.
|
|
220
|
+
- **Don't add `open`/`is-open` classes.** State lives in `[aria-expanded]`
|
|
221
|
+
and `:popover-open`; a class-based copy will drift.
|
|
222
|
+
- **Don't wrap the button in the popover element** — the invoker must live
|
|
223
|
+
outside the drawer or it disappears with it.
|
|
224
|
+
- The drawer is `position: fixed` in the top layer; giving it a `z-index` or
|
|
225
|
+
a positioned ancestor does nothing (and confuses readers).
|
|
226
|
+
|
|
227
|
+
## Related recipes
|
|
228
|
+
|
|
229
|
+
- `dialog` — modal overlays for content (native `<dialog>`, same zero-JS
|
|
230
|
+
philosophy).
|
|
231
|
+
- `combobox` — another browser-native disclosure pattern.
|