Skip to content

Commit c44964c

Browse files
nextlevelshitMichael Czechowski
authored andcommitted
feat(analytics+blog): web vitals + 2 more posts (#107)
Co-authored-by: Michael Czechowski <mail@dailysh.it> Co-committed-by: Michael Czechowski <mail@dailysh.it>
1 parent 0a95c47 commit c44964c

3 files changed

Lines changed: 365 additions & 0 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
---
2+
title: "@layer — Finally Control CSS Specificity Without `!important`"
3+
description: "Cascade layers let you decide which rules win, regardless of selector specificity. The end of utility-class arms races and `!important` graveyards."
4+
date: 2026-05-10
5+
slug: cascade-layers-control-specificity
6+
tags: [css, architecture]
7+
---
8+
9+
Every large CSS codebase eventually accumulates `!important` declarations. Bootstrap utilities use them. Tailwind's `!` prefix exists for them. Frameworks add them defensively, then your overrides need their own `!important`, then someone else's reset wins anyway. Cascade layers stop the war.
10+
11+
## The basic idea
12+
13+
```css
14+
@layer reset, base, components, utilities;
15+
16+
@layer reset {
17+
* { margin: 0; padding: 0; }
18+
}
19+
20+
@layer base {
21+
body { font: 16px/1.5 system-ui, sans-serif; color: #1f2937; }
22+
}
23+
24+
@layer components {
25+
.button { padding: .75rem 1rem; border-radius: 6px; }
26+
}
27+
28+
@layer utilities {
29+
.mt-4 { margin-top: 1rem; }
30+
}
31+
```
32+
33+
The first line declares an order. **Later layers always beat earlier layers**, regardless of selector specificity. So an unstyled `.mt-4` (one class) wins over `.button.button-primary[data-state="active"]` (huge specificity) — because it's in a layer declared later.
34+
35+
Anything *outside* layers wins over *anything inside* layers.
36+
37+
## Why this changes everything
38+
39+
Before layers, the cascade was:
40+
41+
1. !important
42+
2. inline styles
43+
3. id selectors
44+
4. class/attribute/pseudo-class selectors
45+
5. element selectors
46+
47+
You "won" by being more specific. The arms race: `.btn``.btn.btn-primary``body .btn.btn-primary``!important` → end of conversation.
48+
49+
With layers:
50+
51+
1. !important (still nuclear)
52+
2. inline styles
53+
3. **Unlayered styles**
54+
4. **Layers, in declared order (last wins)**
55+
56+
Inside a layer, normal specificity still applies — but only against other rules in the same layer. Cross-layer comparisons obey the order. Predictable.
57+
58+
## Real example: third-party + your overrides
59+
60+
```css
61+
/* In a single stylesheet */
62+
@layer vendor, components, utilities, overrides;
63+
64+
@layer vendor {
65+
/* Bootstrap dump goes here */
66+
@import url("bootstrap.css");
67+
}
68+
69+
@layer components {
70+
.card { background: #fff; padding: 1.5rem; }
71+
}
72+
73+
@layer utilities {
74+
.p-0 { padding: 0; }
75+
}
76+
77+
@layer overrides {
78+
.card { background: #f3f4f6; }
79+
}
80+
```
81+
82+
Your `.card` override in the `overrides` layer beats Bootstrap's, **without** needing `!important` or higher specificity. Even Bootstrap's `.card.card-primary[data-bs-toggle]` loses, because the layer order said so.
83+
84+
## Three patterns I keep using
85+
86+
**1. Reset that doesn't fight everything else**
87+
88+
```css
89+
@layer reset, *;
90+
91+
@layer reset {
92+
* { margin: 0; padding: 0; box-sizing: border-box; }
93+
}
94+
```
95+
96+
The `*` in the layer-order means "every other layer goes after reset." Reset rules sit at the bottom, every named or unnamed layer wins.
97+
98+
**2. Tailwind-style utilities that always win**
99+
100+
```css
101+
@layer base, components, utilities;
102+
103+
@layer utilities {
104+
.text-center { text-align: center; }
105+
.hidden { display: none; }
106+
}
107+
```
108+
109+
Now `.hidden` works without the Tailwind `!` prefix or `!important`. It's just last in the order.
110+
111+
**3. Theming**
112+
113+
```css
114+
@layer theme.light, theme.dark;
115+
116+
@layer theme.light {
117+
:root { --bg: white; --text: #111; }
118+
}
119+
120+
@layer theme.dark {
121+
[data-theme="dark"] { --bg: #111; --text: #fafafa; }
122+
}
123+
```
124+
125+
Dot-notation creates sublayers. Useful for organization; works the same as flat layers for ordering.
126+
127+
## Caveats
128+
129+
- **Anonymous unlayered styles win.** If a stylesheet is partially layered, the unlayered parts beat *all* layers. Not always what you want — declare a default layer like `@layer base { ... }` to avoid surprises.
130+
- **`@import` with layer**: `@import url("x.css") layer(vendor);` puts the whole imported sheet into one layer. Useful for wrapping legacy CSS.
131+
- **Nested layers** (`@layer components.card { ... }`) are cool but rarely needed. Keep it flat.
132+
- **`!important` STILL nukes everything.** Cascade layers don't replace `!important` — they replace the *need* for it.
133+
134+
## Browser support (2026-05)
135+
136+
[Caniuse](https://caniuse.com/css-cascade-layers): all major browsers since Q1 2022. Chrome 99, Safari 15.4, Firefox 97. Three years of universal support.
137+
138+
## What this kills
139+
140+
- **CSS-in-JS libraries that exist only to scope styles**. If you wanted styled-components for "components win over global," `@layer components` does it natively.
141+
- **The `!important` epidemic**. Most uses of `!important` are workarounds for "this should win." Layers express that intent declaratively.
142+
- **Shadow DOM as a "specificity hack"**. Layers don't replace Shadow DOM's encapsulation, but for simple "make my override win," they're cheaper.
143+
144+
I removed every `!important` from a 2k-line stylesheet last quarter using nothing but cascade layers and shorter selectors. The cascade became readable again.
145+
146+
---
147+
148+
Hands-on CSS practice on [Code Crispies](/) — see the [`css-fundamentals`](/css-fundamentals/0/) module for selectors and specificity exercises with live preview.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
---
2+
title: "Scroll-Driven Animations — Keyframes Triggered by Scroll, No JS"
3+
description: "animation-timeline lets CSS animations advance based on scroll position. Reading-progress bars, parallax, scrubbed reveals — all without IntersectionObserver."
4+
date: 2026-05-10
5+
slug: scroll-driven-animations-css
6+
tags: [css, animation, scroll]
7+
---
8+
9+
Animations bound to scroll position used to mean GreenSock + ScrollMagic, or hand-rolled `IntersectionObserver` + RAF loops. CSS now ties any keyframe animation to scroll progress — declaratively, with no JavaScript.
10+
11+
## Reading progress bar in 8 lines
12+
13+
```css
14+
@keyframes grow {
15+
from { width: 0; }
16+
to { width: 100%; }
17+
}
18+
19+
.progress-bar {
20+
position: fixed;
21+
top: 0; left: 0;
22+
height: 4px;
23+
background: var(--accent);
24+
animation: grow linear;
25+
animation-timeline: scroll(root);
26+
}
27+
```
28+
29+
That's it. The `animation-timeline: scroll(root)` ties the animation's progress to scrolling the root scroller (i.e. the page). Scroll 50% down → animation at 50% → bar at 50% width.
30+
31+
No `requestAnimationFrame`, no scroll listener, no `e.preventDefault()`. The browser does the math.
32+
33+
## Reveal on scroll with `view-timeline`
34+
35+
```css
36+
@keyframes fade-in {
37+
from { opacity: 0; transform: translateY(20px); }
38+
to { opacity: 1; transform: translateY(0); }
39+
}
40+
41+
.card {
42+
animation: fade-in linear;
43+
animation-timeline: view();
44+
animation-range: entry 0% cover 30%;
45+
}
46+
```
47+
48+
`view()` ties the animation to **the element's own viewport progress**. `animation-range` says "play from when the element starts entering to when it's covered 30%." So as the card scrolls into view, it fades in over the first 30% of its travel. Scroll back up → animation reverses.
49+
50+
This is the IntersectionObserver-fade-in pattern, in 4 lines of CSS, with butter-smooth performance (browser-paint-thread, not main-thread JS).
51+
52+
## Animation ranges in plain English
53+
54+
| Range keyword | Means |
55+
|---|---|
56+
| `cover 0%` | the element first touches the viewport |
57+
| `entry 0%` | element starts entering the viewport |
58+
| `entry 100%` | element is fully inside the viewport |
59+
| `contain 0%` | element is fully inside (same as entry 100%) |
60+
| `contain 100%` | element starts to leave |
61+
| `exit 0%` | element starts leaving |
62+
| `exit 100%` | element is fully out of view |
63+
| `cover 100%` | element no longer touches the viewport |
64+
65+
You can mix: `animation-range: entry 0% exit 100%` runs the animation from "first appears" to "fully gone" — full-trip parallax.
66+
67+
## Horizontal scroller that animates as it scrolls
68+
69+
```css
70+
.gallery {
71+
display: flex;
72+
overflow-x: scroll;
73+
scroll-snap-type: x mandatory;
74+
}
75+
76+
.gallery img {
77+
scroll-snap-align: center;
78+
animation: zoom-in linear;
79+
animation-timeline: view(inline);
80+
animation-range: contain 0% contain 100%;
81+
}
82+
83+
@keyframes zoom-in {
84+
0%, 100% { transform: scale(0.85); opacity: 0.6; }
85+
50% { transform: scale(1); opacity: 1; }
86+
}
87+
```
88+
89+
`view(inline)` watches the inline (horizontal) axis. Each image scales up at center, scales down at edges. Pure scroll-driven, no JS sync.
90+
91+
## Multiple animations sharing one timeline
92+
93+
```css
94+
.section {
95+
scroll-timeline: --section-trip block;
96+
}
97+
98+
.section h2,
99+
.section .icon,
100+
.section p {
101+
animation-timeline: --section-trip;
102+
animation-range: entry 20% entry 80%;
103+
}
104+
105+
.section h2 { animation: fade-in; animation-delay: 0%; }
106+
.section .icon { animation: fade-in; animation-delay: 10%; }
107+
.section p { animation: fade-in; animation-delay: 20%; }
108+
```
109+
110+
`scroll-timeline` on the section names a timeline. Children attach to it via `animation-timeline: --section-trip`. Stagger via `animation-delay` (interpreted as % of the timeline). Choreographed reveals without orchestration JavaScript.
111+
112+
## Performance notes
113+
114+
- Scroll-driven animations run on the **compositor** thread. Doesn't matter how heavy your JS main thread is — these animations stay smooth.
115+
- `animation-timeline: scroll()` is essentially free. The browser already tracks scroll position; you're just listening.
116+
- `view()` is slightly more work because the browser has to track each element's intersection — but still cheaper than `IntersectionObserver` callbacks.
117+
118+
## Browser support (2026-05)
119+
120+
[Caniuse](https://caniuse.com/css-scroll-driven-animations):
121+
122+
- Chrome / Edge 115 (July 2023)
123+
- Safari: in flight, behind a flag
124+
- Firefox: not yet shipped
125+
126+
For unsupported browsers, the animation falls back to its initial state (or you can set sensible static styles via `@supports not (animation-timeline: view())`). No breakage, just no scroll-trigger.
127+
128+
```css
129+
@supports not (animation-timeline: view()) {
130+
.card { opacity: 1; transform: none; }
131+
}
132+
```
133+
134+
## When NOT to use it
135+
136+
- **Animations that need to react to non-scroll events** — clicks, hover, network state. Stay with `transition` or JS.
137+
- **Triggering side-effects** (analytics events, lazy-load) on scroll. The animation doesn't tell JS anything happened. Keep `IntersectionObserver` for that side of the work.
138+
- **Scroll-jacking effects** (snap to next section). Different concept; use `scroll-snap-type` for that.
139+
140+
## What this replaces
141+
142+
- `IntersectionObserver` setups for fade-in-on-scroll → scroll-driven CSS
143+
- ScrollTrigger / parallax libraries for simple effects → scroll-driven CSS
144+
- Hand-rolled scroll listeners for reading-progress bars → 8 lines of CSS
145+
146+
I rewrote a marketing page's parallax + reveal effects last week. -47 KB JavaScript, +12 lines of CSS, smoother on mobile because everything moved off the main thread.
147+
148+
---
149+
150+
Animations live in the [`transitions-animations`](/transitions-animations/0/) module on [Code Crispies](/) — interactive practice with live preview.

src/app.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,73 @@ document.addEventListener("securitypolicyviolation", (e) => {
6262
}
6363
});
6464

65+
// Web Vitals — bucket the value (avoid noisy umami events with raw ms).
66+
// Buckets follow Google's good/needs-improvement/poor thresholds so the
67+
// dashboard can report % of sessions in each bucket.
68+
function _webVitalBucket(name, value) {
69+
const t = {
70+
LCP: [2500, 4000],
71+
INP: [200, 500],
72+
CLS: [0.1, 0.25],
73+
FCP: [1800, 3000],
74+
TTFB: [800, 1800]
75+
}[name];
76+
if (!t) return "unknown";
77+
if (value <= t[0]) return "good";
78+
if (value <= t[1]) return "needs-improvement";
79+
return "poor";
80+
}
81+
function _emitVital(name, value) {
82+
track("web_vital", {
83+
metric: name,
84+
bucket: _webVitalBucket(name, value),
85+
value: Math.round(value),
86+
path: window.location.pathname
87+
});
88+
}
89+
try {
90+
// LCP — largest paint within the viewport. Last entry before user
91+
// interaction wins.
92+
new PerformanceObserver((list) => {
93+
const entries = list.getEntries();
94+
_emitVital("LCP", entries[entries.length - 1].renderTime || entries[entries.length - 1].loadTime || 0);
95+
}).observe({ type: "largest-contentful-paint", buffered: true });
96+
97+
// CLS — sum of all unexpected layout shifts in a 1s window
98+
let clsValue = 0;
99+
new PerformanceObserver((list) => {
100+
for (const entry of list.getEntries()) {
101+
if (!entry.hadRecentInput) clsValue += entry.value;
102+
}
103+
}).observe({ type: "layout-shift", buffered: true });
104+
addEventListener("visibilitychange", () => {
105+
if (document.visibilityState === "hidden" && clsValue > 0) {
106+
_emitVital("CLS", clsValue * 1000); // 0.05 → 50, fits int rounding
107+
}
108+
}, { once: true });
109+
110+
// INP — slowest interaction-to-next-paint observed
111+
let worstINP = 0;
112+
new PerformanceObserver((list) => {
113+
for (const entry of list.getEntries()) {
114+
if (entry.duration > worstINP) worstINP = entry.duration;
115+
}
116+
}).observe({ type: "event", buffered: true, durationThreshold: 16 });
117+
addEventListener("visibilitychange", () => {
118+
if (document.visibilityState === "hidden" && worstINP > 0) _emitVital("INP", worstINP);
119+
}, { once: true });
120+
121+
// FCP + TTFB from the navigation entry
122+
const nav = performance.getEntriesByType("navigation")[0];
123+
if (nav) _emitVital("TTFB", nav.responseStart);
124+
new PerformanceObserver((list) => {
125+
const fcp = list.getEntries().find((e) => e.name === "first-contentful-paint");
126+
if (fcp) _emitVital("FCP", fcp.startTime);
127+
}).observe({ type: "paint", buffered: true });
128+
} catch {
129+
// PerformanceObserver missing in old browsers — silent skip.
130+
}
131+
65132
// Simplified state - LessonEngine now manages lesson state and progress
66133
const state = {
67134
userSettings: {

0 commit comments

Comments
 (0)