Skip to content

Commit aff5351

Browse files
nextlevelshitMichael Czechowski
authored andcommitted
feat(blog): 4 more posts (view-transitions, @scope, speculation, navigation) (#142)
Co-authored-by: Michael Czechowski <mail@dailysh.it> Co-committed-by: Michael Czechowski <mail@dailysh.it>
1 parent 83df73a commit aff5351

8 files changed

Lines changed: 638 additions & 0 deletions

blog/2026-05-10-css-scope-rule.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
---
2+
title: "@scope — Style Without Naming Things"
3+
description: "@scope lets you style components without BEM, without CSS modules, without compile steps. Specificity stays low, leaks stop, and tree-walking selectors stay fast."
4+
date: 2026-05-10
5+
slug: css-scope-rule
6+
tags: [css, architecture]
7+
---
8+
9+
The hardest problem in CSS isn't centering things — it's preventing styles from leaking. The standard solutions all add overhead: BEM names get long, CSS Modules need a build step, Shadow DOM needs a custom element. The `@scope` rule is the platform's answer.
10+
11+
## The basic idea
12+
13+
```html
14+
<article class="card">
15+
<h2>Title</h2>
16+
<p>Lorem...</p>
17+
<footer><a href="#">Read more</a></footer>
18+
</article>
19+
```
20+
21+
```css
22+
@scope (.card) {
23+
h2 { font-size: 1.25rem; color: #1f2937; }
24+
p { color: #6b7280; }
25+
footer a { color: #4f46e5; }
26+
}
27+
```
28+
29+
The selectors inside `@scope` only match descendants of `.card`. No BEM, no nesting wrapper, no `.card h2 {}` repetition. Bare `h2` is enough.
30+
31+
## Scope boundaries
32+
33+
The real magic is the second argument — `to`:
34+
35+
```css
36+
@scope (.card) to (.card-footer) {
37+
h2 { font-size: 1.25rem; }
38+
p { color: #6b7280; }
39+
}
40+
```
41+
42+
Now `h2` and `p` only style the parts of `.card` that come *before* `.card-footer`. Anything inside the footer is excluded — even if it's a `<p>`. Useful for nested components: outer scope, inner scope, no leakage between them.
43+
44+
## Why this beats nesting alone
45+
46+
Native nesting (which we covered [here](/blog/css-nesting-native-no-postcss/)) does this:
47+
48+
```css
49+
.card {
50+
h2 { font-size: 1.25rem; }
51+
p { color: #6b7280; }
52+
}
53+
```
54+
55+
Compiles down to `.card h2 { ... }` and `.card p { ... }`. Specificity climbs (`0,1,1`), and there's no `to` boundary. `@scope` keeps specificity at the inner selector's level (`0,0,1` for plain `h2`) and supports boundaries.
56+
57+
## Donut scope (no boundary specified)
58+
59+
```css
60+
@scope (.profile) {
61+
:scope {
62+
border: 1px solid #e5e7eb;
63+
padding: 1rem;
64+
}
65+
h3 { font-weight: 600; }
66+
}
67+
```
68+
69+
`:scope` references the scope root itself, so you can style both the container and its contents in one block. No more `.profile { ... } .profile h3 { ... }` split.
70+
71+
## Proximity-based specificity
72+
73+
The big behavioral difference: when two `@scope` rules match, the one whose root is **closest** to the matched element wins, regardless of source order:
74+
75+
```html
76+
<article class="card theme-blue">
77+
<article class="card theme-red">
78+
<h2>Inner</h2>
79+
</article>
80+
</article>
81+
```
82+
83+
```css
84+
@scope (.theme-blue) { h2 { color: blue; } }
85+
@scope (.theme-red) { h2 { color: red; } }
86+
```
87+
88+
The inner `<h2>` is red, because its closest scope root is `.theme-red`. With normal selectors, you'd need to game source order or specificity. With `@scope`, the DOM proximity decides.
89+
90+
## Component pattern in 2026
91+
92+
A self-contained component without BEM, without Shadow DOM, without modules:
93+
94+
```html
95+
<style>
96+
@scope {
97+
:scope { padding: 1rem; border-radius: 8px; background: var(--bg, white); }
98+
h2 { font-size: 1.25rem; margin: 0 0 .5rem; }
99+
p { margin: 0; color: #6b7280; }
100+
}
101+
</style>
102+
<article>
103+
<h2>Crispy Cereal</h2>
104+
<p>Stays crunchy in milk.</p>
105+
</article>
106+
```
107+
108+
The `<style>` is inside an HTML island; `@scope` with no argument scopes to the parent of the `<style>` element. The styles only apply to *that* article. Drop the same component in twice — they don't interfere.
109+
110+
## When NOT to use it
111+
112+
- **Global resets** (`* { box-sizing: border-box }`) — these are deliberately global.
113+
- **Design tokens / theme variables** — keep on `:root` for inheritance.
114+
- **One-off page styles** — overkill if you've got 5 selectors total.
115+
116+
`@scope` shines for component CSS, especially in design systems and multi-author codebases where leakage is the dominant cost.
117+
118+
## Browser support
119+
120+
- Chrome / Edge 118+ (October 2023)
121+
- Safari 17.4+ (March 2024)
122+
- Firefox: in development as of 2026
123+
124+
For Firefox today, the entire `@scope` block fails to parse — so anything inside is lost. Either gate with `@supports at-rule(@scope)` or accept that pre-shipping Firefox sees the inner styles unscoped.
125+
126+
```css
127+
@supports at-rule(@scope) {
128+
@scope (.card) {
129+
/* ... */
130+
}
131+
}
132+
```
133+
134+
## What this kills (over time)
135+
136+
- BEM naming conventions (`.card__title--large`)
137+
- CSS Modules build pipelines for scoping
138+
- Most uses of `:where()` for "specificity flattening"
139+
- Style overrides that only exist because some other component leaked into your tree
140+
141+
When `@scope` is universally available, the cleanest CSS shifts from "name everything uniquely" to "wrap each component in a scope and write plain selectors."
142+
143+
---
144+
145+
Hands-on selector practice in [`css-basic-selectors`](/css-basic-selectors/0/) on [Code Crispies](/).

blog/2026-05-10-navigation-api.md

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
---
2+
title: "The Navigation API — Finally a Sane Router Primitive"
3+
description: "history.pushState was the only routing tool for a decade — and it was awful. The Navigation API is the actual primitive routers should have been built on."
4+
date: 2026-05-10
5+
slug: navigation-api
6+
tags: [javascript, navigation, architecture]
7+
---
8+
9+
Every SPA router on npm — react-router, vue-router, @tanstack/router — exists because `history.pushState` is unusable as a routing primitive. No event for clicked links. No way to intercept navigation. Async lifecycle? Roll your own. The Navigation API is the platform fix.
10+
11+
## The fundamental win: navigate event
12+
13+
```js
14+
navigation.addEventListener("navigate", (event) => {
15+
if (!event.canIntercept || event.hashChange) return;
16+
17+
const url = new URL(event.destination.url);
18+
if (url.origin !== location.origin) return;
19+
20+
event.intercept({
21+
handler: async () => {
22+
const html = await fetch(url, { signal: event.signal }).then((r) => r.text());
23+
document.querySelector("main").innerHTML = html;
24+
}
25+
});
26+
});
27+
```
28+
29+
That's a complete client-side router. ~10 lines.
30+
31+
What it gives you:
32+
- Fires for every navigation: clicks, `navigation.navigate()`, back/forward, even `<form action>` submissions
33+
- `event.intercept(handler)` — take over the navigation, do async work, the URL bar updates
34+
- `event.signal` — automatic AbortSignal that cancels if the user navigates away mid-load
35+
- `event.hashChange` lets you skip in-page anchor jumps
36+
- `event.canIntercept` returns false for cross-origin / download requests
37+
38+
## Compare to the old way
39+
40+
```js
41+
// pre-Navigation API
42+
window.addEventListener("popstate", handlePopState);
43+
document.addEventListener("click", (e) => {
44+
const link = e.target.closest("a[href]");
45+
if (!link) return;
46+
if (link.target === "_blank") return;
47+
if (link.href.startsWith("mailto:")) return;
48+
if (link.host !== location.host) return;
49+
if (e.metaKey || e.ctrlKey) return;
50+
// ... 20 more edge cases ...
51+
e.preventDefault();
52+
history.pushState(null, "", link.href);
53+
handleRoute(link.href);
54+
});
55+
```
56+
57+
Every framework router has this 200-line click interceptor. Navigation API replaces it with `addEventListener("navigate", ...)`.
58+
59+
## Async navigations with state
60+
61+
```js
62+
navigation.addEventListener("navigate", (event) => {
63+
if (!event.canIntercept) return;
64+
65+
event.intercept({
66+
handler: async () => {
67+
showLoadingBar();
68+
try {
69+
await renderRoute(event.destination.url);
70+
} finally {
71+
hideLoadingBar();
72+
}
73+
},
74+
// Tell the browser: focus management, scroll restoration
75+
focusReset: "after-transition",
76+
scroll: "after-transition"
77+
});
78+
});
79+
```
80+
81+
`focusReset` moves keyboard focus to the new view. `scroll` restores scroll position on back/forward. Both are A11y wins you usually have to hand-build.
82+
83+
## Programmatic navigation with await
84+
85+
```js
86+
const result = await navigation.navigate("/lessons/42").finished;
87+
console.log("navigation done");
88+
```
89+
90+
`navigation.navigate()` returns `{ committed, finished }` — two promises. `committed` resolves when the URL changes; `finished` when the handler finishes. So you can `await` a route load like any other async operation.
91+
92+
## Pair with View Transitions
93+
94+
```js
95+
navigation.addEventListener("navigate", (event) => {
96+
if (!event.canIntercept || event.hashChange) return;
97+
98+
event.intercept({
99+
handler: async () => {
100+
if (!document.startViewTransition) {
101+
return renderRoute(event.destination.url);
102+
}
103+
const transition = document.startViewTransition(() =>
104+
renderRoute(event.destination.url)
105+
);
106+
await transition.finished;
107+
}
108+
});
109+
});
110+
```
111+
112+
Click → fetch → swap DOM → animate. The combo gives you SPA-feel without a router framework.
113+
114+
## Going back / forward
115+
116+
`navigation.entries()` returns every entry in the history (just for your origin):
117+
118+
```js
119+
const entries = navigation.entries();
120+
const currentIdx = navigation.currentEntry.index;
121+
const previous = entries[currentIdx - 1];
122+
123+
if (previous) {
124+
navigation.traverseTo(previous.key);
125+
}
126+
```
127+
128+
No more guessing what's in `history.state` — you have a real list of entries with stable keys.
129+
130+
## Form intercepts
131+
132+
```js
133+
navigation.addEventListener("navigate", (event) => {
134+
if (event.formData) {
135+
// It's a form submission, formData is the submitted FormData
136+
event.intercept({
137+
handler: async () => {
138+
const response = await fetch(event.destination.url, {
139+
method: "POST",
140+
body: event.formData
141+
});
142+
renderRoute(event.destination.url, await response.text());
143+
}
144+
});
145+
}
146+
});
147+
```
148+
149+
You can intercept form submits *and* GET navigations through one event. The old way needed separate handlers for `submit` events.
150+
151+
## Browser support
152+
153+
- Chrome / Edge 102+ (May 2022)
154+
- Safari 18+ (September 2024)
155+
- Firefox: in development as of 2026
156+
157+
For Firefox today: `if (!window.navigation) { /* fall back to old click-intercept */ }`. Most existing routers do this internally already, but if you're hand-rolling, write the fallback.
158+
159+
## When NOT to reach for it
160+
161+
- **Multi-document apps** (server-rendered without hydration) — Speculation Rules cover that case better
162+
- **Tiny apps with 2 routes** — keep using `<a>` and full reloads, not worth the wiring
163+
- **Cross-origin SSO redirects**`canIntercept` returns false anyway, the browser owns this
164+
165+
## What this kills
166+
167+
- Most of `history.pushState` direct usage
168+
- Click-interceptor boilerplate in vanilla SPAs
169+
- The motivation for "minimal SPA router" libraries
170+
- Hand-rolled scroll/focus restoration
171+
172+
When the Navigation API is universally available (Firefox roadmap suggests 2027), the question "which router should I use?" becomes "do I need a router at all, or just `addEventListener('navigate', ...)`?"
173+
174+
---
175+
176+
Practice JS basics in the [`js-events`](/js-events/0/) module on [Code Crispies](/) — covers `addEventListener` and event delegation.

0 commit comments

Comments
 (0)