Modern CSS 2026: The Complete Guide to Building Interactions Without JavaScript
Five years ago, building a dark mode toggle, scroll-triggered animations, or a positioned tooltip required JavaScript — sometimes dozens of lines of it. In 2026, CSS handles all of this natively at 60fps.
This isn't about replacing JavaScript entirely. It's about recognizing that CSS has grown into a programming language for UI interactions. The less JavaScript you ship for styling and animation, the faster your site loads, the smoother it runs, and the fewer bugs you debug.
In this module, you'll build a fully interactive landing page using zero JavaScript:
- Dark mode toggle using CSS Custom Properties and
:has() - Scroll-triggered fade-ins using
animation-timeline: view() - Animated buttons with
transitionand@keyframes - Anchor-positioned tooltips that stay attached to their triggers
- Cascade control with
@layerso third-party styles never override yours
Open a new folder called modern-css-landing. We're building from scratch to isolate these concepts.
Part 1: CSS Custom Properties — Theming Without Preprocessors
CSS Custom Properties (variables) are live, inheritable values that cascade like any other property. Unlike Sass variables, they can be updated at runtime by the browser — which makes them perfect for theming.
Defining and Using Variables
1:root { 2 /* Brand palette */ 3 --color-primary: #6366f1; 4 --color-primary-dark: #4f46e5; 5 6 /* Neutral scale */ 7 --color-bg: #ffffff; 8 --color-surface: #f8fafc; 9 --color-text: #0f172a; 10 --color-text-muted: #64748b; 11 --color-border: #e2e8f0; 12 13 /* Typography scale */ 14 --font-body: 'Inter', system-ui, sans-serif; 15 --text-base: 1rem; 16 17 /* Spacing scale */ 18 --space-sm: 1rem; 19 --space-md: 2rem; 20 --space-lg: 4rem; 21 22 /* Motion */ 23 --transition-fast: 150ms ease; 24 --transition-base: 250ms ease; 25} 26 27body { 28 background-color: var(--color-bg); 29 color: var(--color-text); 30 font-family: var(--font-body); 31}
Scoping Variables
Variables inherit down the DOM. You can redefine them at any scope:
1/* Global */ 2:root { --card-bg: #ffffff; } 3 4/* Component-level override */ 5.dark-section { 6 --card-bg: #1e293b; 7 --color-text: #f1f5f9; 8 background: #0f172a; 9} 10 11.dark-section .card { 12 background: var(--card-bg); /* Uses the local override */ 13}
The Dark Mode Toggle (Zero JavaScript)
The trick is a hidden checkbox and the :has() selector. When checked, :has() targets the root element and swaps the entire color scheme.
1<!-- In your header --> 2<input type="checkbox" id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode"> 3<label for="theme-toggle" class="theme-toggle-label"> 4 <span class="toggle-track"> 5 <span class="toggle-thumb"></span> 6 </span> 7</label>
1/* Light mode defaults */ 2:root { 3 --color-bg: #ffffff; 4 --color-surface: #f8fafc; 5 --color-text: #0f172a; 6 --color-text-muted: #64748b; 7 --color-border: #e2e8f0; 8 --shadow-color: rgb(0 0 0 / 0.1); 9} 10 11/* Dark mode overrides */ 12:root:has(#theme-toggle:checked) { 13 --color-bg: #0f172a; 14 --color-surface: #1e293b; 15 --color-text: #f1f5f9; 16 --color-text-muted: #94a3b8; 17 --color-border: #334155; 18 --shadow-color: rgb(0 0 0 / 0.4); 19} 20 21/* Hide the actual checkbox */ 22.theme-toggle { 23 position: absolute; 24 opacity: 0; 25 width: 0; 26 height: 0; 27} 28 29/* Style the label as a toggle switch */ 30.theme-toggle-label { 31 cursor: pointer; 32 display: inline-flex; 33 align-items: center; 34} 35 36.toggle-track { 37 width: 48px; 38 height: 26px; 39 background: var(--color-border); 40 border-radius: 999px; 41 position: relative; 42 transition: background var(--transition-base); 43} 44 45.toggle-thumb { 46 position: absolute; 47 top: 3px; 48 left: 3px; 49 width: 20px; 50 height: 20px; 51 background: var(--color-surface); 52 border-radius: 50%; 53 transition: transform var(--transition-base); 54 box-shadow: 0 1px 3px var(--shadow-color); 55} 56 57/* Checked state */ 58:root:has(#theme-toggle:checked) .toggle-track { 59 background: var(--color-primary); 60} 61 62:root:has(#theme-toggle:checked) .toggle-thumb { 63 transform: translateX(22px); 64}
How It Works: The checkbox is hidden but functional. Clicking the label toggles it.
:has(#theme-toggle:checked)on:rootdetects the state and redefines every color variable. Every element usingvar(--color-bg)updates instantly — no JavaScript, no flash of unstyled content, no FOUC.
Part 2: Mathematical CSS — calc(), min(), max(), clamp()
These functions let your styles respond to context without media queries.
1/* Fluid typography: never smaller than 1rem, never larger than 1.25rem */ 2body { 3 font-size: clamp(1rem, 0.9rem + 0.5vw, 1.25rem); 4} 5 6/* Container that respects min padding but fills the screen */ 7.container { 8 width: min(1200px, 100% - 2rem); 9 margin-inline: auto; 10} 11 12/* Hero height: at least 400px, at most 70% of viewport */ 13.hero { 14 min-height: max(400px, 70vh); 15} 16 17/* Dynamic spacing based on viewport but capped */ 18.section { 19 padding-block: clamp(2rem, 5vh, 5rem); 20} 21 22/* Calculate button width minus icon */ 23.button-text { 24 width: calc(100% - 2.5rem); 25} 26 27/* Combine them: padding that scales with font size AND viewport */ 28.card { 29 padding: clamp(1rem, 3%, 2.5rem); 30}
| Function | Purpose |
|---|---|
clamp(min, preferred, max) | Sets a value with floor and ceiling |
min(a, b) | Uses the smaller of two values |
max(a, b) | Uses the larger of two values |
calc(a + b) | Computes dynamic values at runtime |
Pro Tip:
width: min(800px, 100% - 2rem)is the modern replacement formax-width: 800px; margin: 0 auto; padding: 0 1rem;. It handles responsiveness in one line.
Part 3: Transitions and Animations — The 60fps Rule
Browsers animate certain properties on the GPU. Others force the CPU to recalculate layout — causing jank.
GPU-Accelerated Properties (Safe to Animate)
1/* These animate at 60fps because they don't trigger layout recalculation */ 2transform: translateX(100px); 3transform: scale(1.1); 4transform: rotate(15deg); 5opacity: 0.5; 6filter: blur(4px);
Properties That Cause Jank (Avoid Animating)
1/* These force layout recalculation on every frame */ 2width: 200px; 3height: 200px; 4top: 100px; 5left: 100px; 6margin: 20px; 7padding: 20px;
Transitions: State Changes
1.button { 2 background: var(--color-primary); 3 transform: scale(1); 4 transition: 5 background var(--transition-fast), 6 transform var(--transition-fast), 7 box-shadow var(--transition-fast); 8} 9 10.button:hover { 11 background: var(--color-primary-dark); 12 transform: scale(1.02); 13 box-shadow: 0 4px 12px var(--shadow-color); 14} 15 16.button:active { 17 transform: scale(0.98); 18}
Keyframe Animations: Complex Sequences
1@keyframes pulse { 2 0%, 100% { transform: scale(1); opacity: 1; } 3 50% { transform: scale(1.05); opacity: 0.8; } 4} 5 6.loading-indicator { 7 animation: pulse 2s ease-in-out infinite; 8} 9 10@keyframes slide-up { 11 from { 12 opacity: 0; 13 transform: translateY(40px); 14 } 15 to { 16 opacity: 1; 17 transform: translateY(0); 18 } 19} 20 21.hero-content { 22 animation: slide-up 0.8s ease-out forwards; 23}
The animation Shorthand
1animation: name duration timing-function delay iteration-count direction fill-mode; 2 3/* Example */ 4.animated { 5 animation: slide-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.2s 1 normal forwards; 6}
| Value | Meaning |
|---|---|
forwards | Keeps final keyframe state after animation ends |
backwards | Applies first keyframe before animation starts |
both | Combines forwards and backwards |
infinite | Loops forever |
alternate | Reverses direction each cycle |
Part 4: Scroll-Driven Animations — No Intersection Observer Needed
This is the biggest CSS feature of the decade. Elements can animate based on scroll position — no JavaScript, no libraries, no performance overhead.
The Two Animation Timelines
1/* 1. Scroll progress: animation tied to page scroll */ 2.parallax-bg { 3 animation: parallax linear; 4 animation-timeline: scroll(root); /* Tied to page scroll */ 5} 6 7/* 2. View progress: animation tied to element entering viewport */ 8.reveal-card { 9 animation: fade-in-up linear both; 10 animation-timeline: view(); /* Tied to element's visibility */ 11 animation-range: entry 25% cover 50%; /* Start at 25% entry, end at 50% coverage */ 12}
Building Scroll-Triggered Reveals
1@keyframes fade-in-up { 2 from { 3 opacity: 0; 4 transform: translateY(60px) scale(0.95); 5 } 6 to { 7 opacity: 1; 8 transform: translateY(0) scale(1); 9 } 10} 11 12/* Apply to any element you want to reveal on scroll */ 13.reveal { 14 animation: fade-in-up linear both; 15 animation-timeline: view(); 16 animation-range: entry 10% cover 40%; 17} 18 19/* Staggered delays using animation-delay with scroll */ 20.feature-card:nth-child(1) { animation-range: entry 10% cover 40%; } 21.feature-card:nth-child(2) { animation-range: entry 15% cover 45%; } 22.feature-card:nth-child(3) { animation-range: entry 20% cover 50%; }
Scroll-Linked Progress Bar
1@keyframes grow-width { 2 from { transform: scaleX(0); } 3 to { transform: scaleX(1); } 4} 5 6.reading-progress { 7 position: fixed; 8 top: 0; 9 left: 0; 10 height: 3px; 11 background: var(--color-primary); 12 transform-origin: left; 13 animation: grow-width linear; 14 animation-timeline: scroll(root); 15 z-index: 1000; 16}
Browser Support: Scroll-driven animations work in Chrome/Edge 115+, Safari 18+, and Firefox 110+. For older browsers, the animation simply doesn't run — the content is still fully visible. This is progressive enhancement at its best.
Part 5: Anchor Positioning — Tooltips Without Coordinate Math
Anchor positioning lets you tether an element to another element. The browser handles all positioning, collision detection, and overflow logic.
Basic Tooltip Pattern
1<button class="tooltip-trigger" style="anchor-name: --tooltip-btn;"> 2 Hover for Info 3</button> 4<div class="tooltip" style="position-anchor: --tooltip-btn;"> 5 This tooltip is positioned purely with CSS 6</div>
1.tooltip-trigger { 2 anchor-name: --tooltip-btn; 3} 4 5.tooltip { 6 position: absolute; 7 position-anchor: --tooltip-btn; 8 position-area: top; /* Position above the anchor */ 9 margin-bottom: 8px; /* Gap between tooltip and button */ 10 11 /* Styling */ 12 background: var(--color-text); 13 color: var(--color-bg); 14 padding: 0.5rem 1rem; 15 border-radius: 6px; 16 font-size: 0.875rem; 17 white-space: nowrap; 18 opacity: 0; 19 pointer-events: none; 20 transition: opacity var(--transition-fast); 21} 22 23/* Show on hover */ 24.tooltip-trigger:hover + .tooltip, 25.tooltip-trigger:focus + .tooltip { 26 opacity: 1; 27}
Handling Overflow with Fallbacks
If the tooltip would overflow the viewport, position-try-fallbacks automatically flips it:
1.tooltip { 2 position-anchor: --tooltip-btn; 3 position-area: top; 4 position-try-fallbacks: --bottom-fallback; 5} 6 7@position-try --bottom-fallback { 8 position-area: bottom; 9 margin-top: 8px; 10 margin-bottom: 0; 11}
Advanced: Popover API + Anchor Positioning
1<button class="menu-trigger" popovertarget="menu" style="anchor-name: --menu-btn;"> 2 Open Menu 3</button> 4<div id="menu" popover class="dropdown" style="position-anchor: --menu-btn;"> 5 <a href="#">Profile</a> 6 <a href="#">Settings</a> 7 <a href="#">Logout</a> 8</div>
1.menu-trigger { 2 anchor-name: --menu-btn; 3} 4 5.dropdown { 6 position: absolute; 7 position-anchor: --menu-btn; 8 position-area: bottom right; 9 margin-top: 4px; 10 11 /* Popover styling */ 12 background: var(--color-surface); 13 border: 1px solid var(--color-border); 14 border-radius: 8px; 15 box-shadow: 0 10px 40px var(--shadow-color); 16 padding: 0.5rem; 17 min-width: 200px; 18} 19 20.dropdown a { 21 display: block; 22 padding: 0.5rem 1rem; 23 color: var(--color-text); 24 text-decoration: none; 25 border-radius: 4px; 26} 27 28.dropdown a:hover { 29 background: var(--color-bg); 30}
The
popoverattribute handles focus management, escape-to-close, and top-layer rendering automatically. Combined with anchor positioning, you get dropdowns that used to require 200 lines of JavaScript.
Part 6: CSS Layers (@layer) — Taming the Cascade
When you import a CSS framework, its styles fight yours. !important wars begin. @layer solves this by declaring which styles should win — explicitly.
Declaring Layers
1/* Order determines priority: later layers override earlier ones */ 2@layer reset, base, components, utilities; 3 4@layer reset { 5 *, *::before, *::after { 6 box-sizing: border-box; 7 margin: 0; 8 padding: 0; 9 } 10} 11 12@layer base { 13 body { 14 font-family: var(--font-body); 15 line-height: 1.6; 16 color: var(--color-text); 17 } 18 19 a { 20 color: var(--color-primary); 21 } 22} 23 24@layer components { 25 .button { 26 background: var(--color-primary); 27 color: white; 28 padding: 0.75rem 1.5rem; 29 border-radius: 6px; 30 } 31} 32 33@layer utilities { 34 .text-center { text-align: center; } 35 .hidden { display: none !important; } 36}
Unlayered Styles Always Win
If you write CSS outside of any @layer, it overrides everything — regardless of specificity. This is useful for overrides, but dangerous if accidental.
1/* This beats everything, even !important inside layers */ 2h1 { color: red; } 3 4@layer base { 5 h1 { color: blue !important; } /* Loses to unlayered red */ 6}
Importing Third-Party CSS into Layers
1/* Force Bootstrap into a layer that YOU control */ 2@import url('bootstrap.css') layer(framework); 3 4@layer framework, base, components, overrides;
Now your components and overrides layers naturally win over Bootstrap without specificity wars.
Part 7: Scoped CSS (@scope) — Component Boundaries
In large projects, a selector like .card h3 affects every h3 inside every .card — including nested cards. @scope limits styles to a specific DOM subtree.
Basic Scoping
1@scope (.feature-card) { 2 :scope { 3 background: var(--color-surface); 4 border-radius: 12px; 5 padding: 2rem; 6 } 7 8 h3 { 9 font-size: 1.25rem; 10 margin-bottom: 0.5rem; 11 } 12 13 p { 14 color: var(--color-text-muted); 15 } 16 17 /* This only matches .button inside .feature-card */ 18 .button { 19 margin-top: 1rem; 20 } 21}
Scoping with a Lower Boundary
1/* Style .article, but stop at .comments (don't style inside comments) */ 2@scope (.article) to (.comments) { 3 h2 { font-size: 2rem; } 4 p { line-height: 1.8; } 5 a { text-decoration: underline; } 6}
This is powerful for component libraries. Your card styles won't leak into nested components.
Part 8: The :has() Selector — Parent and Sibling Selection
For decades, CSS could only select downward (parent → child). :has() changes everything.
Parent Selection
1/* Style a card ONLY if it contains an image */ 2.card:has(img) { 3 display: grid; 4 grid-template-columns: 200px 1fr; 5 gap: 1.5rem; 6} 7 8/* Style a form field that contains an invalid input */ 9.field:has(input:invalid) { 10 border-color: var(--color-error); 11} 12 13.field:has(input:invalid) label { 14 color: var(--color-error); 15} 16 17/* Style a section that has a dark image (for text contrast) */ 18.hero:has(img[src*="dark"]) { 19 color: white; 20 text-shadow: 0 2px 10px rgba(0,0,0,0.5); 21}
Previous Sibling Selection
1/* Style the label immediately BEFORE a checked checkbox */ 2label:has(+ input[type="checkbox"]:checked) { 3 font-weight: 700; 4 color: var(--color-primary); 5} 6 7/* Style a list item that is immediately followed by .active */ 8li:has(+ .active) { 9 border-bottom: 2px solid var(--color-primary); 10}
Complex Conditions
1/* Card with BOTH an image AND a button */ 2.card:has(img):has(.button) { 3 border: 2px solid var(--color-primary); 4} 5 6/* Equivalent shorthand */ 7.card:has(img, .button) { /* has img OR button */ } 8.card:has(img):has(.button) { /* has img AND button */ }
Part 9: The Mini-Project — Zero-JS Interactive Landing Page
Here's the complete landing page that combines every concept from this module.
index.html
1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Modern CSS 2026 — Zero JavaScript</title> 7 <link rel="preconnect" href="https://fonts.googleapis.com"> 8 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> 9 <link rel="stylesheet" href="styles.css"> 10</head> 11<body> 12 13 <!-- Reading Progress Bar --> 14 <div class="reading-progress" aria-hidden="true"></div> 15 16 <!-- Dark Mode Toggle --> 17 <input type="checkbox" id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode"> 18 19 <div class="page-wrapper"> 20 21 <header class="site-header"> 22 <div class="container header-inner"> 23 <a href="#" class="logo">ModernCSS</a> 24 25 <nav> 26 <ul> 27 <li><a href="#features">Features</a></li> 28 <li><a href="#showcase">Showcase</a></li> 29 <li><a href="#contact">Contact</a></li> 30 </ul> 31 </nav> 32 33 <label for="theme-toggle" class="theme-toggle-label" aria-hidden="true"> 34 <span class="toggle-track"> 35 <span class="toggle-thumb"></span> 36 </span> 37 </label> 38 </div> 39 </header> 40 41 <main> 42 <section class="hero"> 43 <div class="container"> 44 <div class="hero-content reveal"> 45 <h1>CSS in 2026 Does What JavaScript Used To</h1> 46 <p>Dark mode, scroll animations, anchor positioning, and interactive components — all running at 60fps with zero JavaScript.</p> 47 <a href="#features" class="button button-primary"> 48 Explore Features 49 <span class="button-arrow">→</span> 50 </a> 51 </div> 52 </div> 53 </section> 54 55 <section id="features" class="features"> 56 <div class="container"> 57 <h2 class="section-title reveal">Built With Modern CSS</h2> 58 59 <div class="features-grid"> 60 <article class="feature-card reveal"> 61 <div class="feature-icon">🎨</div> 62 <h3>CSS Custom Properties</h3> 63 <p>Live theming with scoped variables. Toggle dark mode instantly without repainting the DOM.</p> 64 </article> 65 66 <article class="feature-card reveal"> 67 <div class="feature-icon">📜</div> 68 <h3>Scroll Animations</h3> 69 <p>Animate elements as they enter the viewport using <code>animation-timeline: view()</code>. No Intersection Observer needed.</p> 70 </article> 71 72 <article class="feature-card reveal"> 73 <div class="feature-icon">📍</div> 74 <h3>Anchor Positioning</h3> 75 <p>Attach tooltips and dropdowns to their triggers. The browser handles collision detection and overflow.</p> 76 <div class="tooltip-wrapper"> 77 <button class="tooltip-trigger" style="anchor-name: --tooltip-1;"> 78 Try Tooltip 79 </button> 80 <div class="tooltip" style="position-anchor: --tooltip-1;"> 81 Positioned purely with CSS anchor positioning! 82 </div> 83 </div> 84 </article> 85 86 <article class="feature-card reveal"> 87 <div class="feature-icon">🎯</div> 88 <h3>:has() Selector</h3> 89 <p>Finally select parent elements and previous siblings. Style cards based on their children.</p> 90 </article> 91 92 <article class="feature-card reveal"> 93 <div class="feature-icon">🥞</div> 94 <h3>@layer Cascade Control</h3> 95 <p>Explicitly order your styles. Third-party frameworks never override your components again.</p> 96 </article> 97 98 <article class="feature-card reveal"> 99 <div class="feature-icon">🔒</div> 100 <h3>@scope Boundaries</h3> 101 <p>Limit selectors to specific DOM subtrees. Build component styles that don't leak.</p> 102 </article> 103 </div> 104 </div> 105 </section> 106 107 <section id="showcase" class="showcase"> 108 <div class="container"> 109 <h2 class="section-title reveal">Smooth by Default</h2> 110 <p class="section-lead reveal">Every interaction on this page uses CSS transitions and keyframe animations. No JavaScript event listeners for hover, focus, or scroll states.</p> 111 112 <div class="showcase-demo reveal"> 113 <div class="demo-box"> 114 <div class="pulse-ring"></div> 115 <span>GPU-Accelerated</span> 116 </div> 117 <div class="demo-box"> 118 <div class="morph-shape"></div> 119 <span>Morphing Shapes</span> 120 </div> 121 <div class="demo-box"> 122 <div class="gradient-shift"></div> 123 <span>Animated Gradients</span> 124 </div> 125 </div> 126 </div> 127 </section> 128 129 <section id="contact" class="contact"> 130 <div class="container"> 131 <h2 class="section-title reveal">Ready to Build?</h2> 132 <p class="section-lead reveal">Start using modern CSS today. Your users will notice the performance difference.</p> 133 134 <form class="contact-form reveal"> 135 <div class="form-row"> 136 <div class="field"> 137 <label for="name">Name</label> 138 <input type="text" id="name" placeholder="Alex Chen" required> 139 </div> 140 <div class="field"> 141 <label for="email">Email</label> 142 <input type="email" id="email" placeholder="alex@example.com" required> 143 </div> 144 </div> 145 <div class="field"> 146 <label for="message">Message</label> 147 <textarea id="message" rows="4" placeholder="Tell me about your project..." required></textarea> 148 </div> 149 <button type="submit" class="button button-primary button-large"> 150 Send Message 151 <span class="button-arrow">→</span> 152 </button> 153 </form> 154 </div> 155 </section> 156 </main> 157 158 <footer class="site-footer"> 159 <div class="container"> 160 <p>Built with modern CSS. Zero JavaScript. 60fps guaranteed.</p> 161 </div> 162 </footer> 163 164 </div> 165 166</body> 167</html>
styles.css
1/* ============================================ 2 LAYER DEFINITIONS 3 ============================================ */ 4@layer reset, base, layout, components, animations, utilities; 5 6/* ============================================ 7 RESET LAYER 8 ============================================ */ 9@layer reset { 10 *, *::before, *::after { 11 box-sizing: border-box; 12 margin: 0; 13 padding: 0; 14 } 15 16 ul, ol { 17 list-style: none; 18 } 19 20 img, picture, video, canvas, svg { 21 display: block; 22 max-width: 100%; 23 } 24 25 input, button, textarea, select { 26 font: inherit; 27 } 28 29 a { 30 text-decoration: none; 31 color: inherit; 32 } 33} 34 35/* ============================================ 36 BASE LAYER: VARIABLES & TYPOGRAPHY 37 ============================================ */ 38@layer base { 39 :root { 40 /* Brand */ 41 --color-primary: #6366f1; 42 --color-primary-dark: #4f46e5; 43 --color-primary-light: #818cf8; 44 45 /* Neutral */ 46 --color-bg: #ffffff; 47 --color-surface: #f8fafc; 48 --color-surface-elevated: #ffffff; 49 --color-text: #0f172a; 50 --color-text-muted: #64748b; 51 --color-border: #e2e8f0; 52 --shadow-color: rgb(15 23 42 / 0.08); 53 54 /* Typography */ 55 --font-body: 'Inter', system-ui, -apple-system, sans-serif; 56 --text-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem); 57 --text-sm: clamp(0.875rem, 0.8rem + 0.35vw, 1rem); 58 --text-base: clamp(1rem, 0.9rem + 0.5vw, 1.125rem); 59 --text-lg: clamp(1.125rem, 1rem + 0.65vw, 1.35rem); 60 --text-xl: clamp(1.5rem, 1.2rem + 1.5vw, 2.5rem); 61 --text-2xl: clamp(2rem, 1.5rem + 2.5vw, 4rem); 62 63 /* Spacing */ 64 --space-xs: 0.5rem; 65 --space-sm: 1rem; 66 --space-md: 2rem; 67 --space-lg: 4rem; 68 --space-xl: 6rem; 69 70 /* Motion */ 71 --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); 72 --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1); 73 --transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1); 74 } 75 76 /* Dark Mode */ 77 :root:has(#theme-toggle:checked) { 78 --color-bg: #0f172a; 79 --color-surface: #1e293b; 80 --color-surface-elevated: #334155; 81 --color-text: #f1f5f9; 82 --color-text-muted: #94a3b8; 83 --color-border: #475569; 84 --shadow-color: rgb(0 0 0 / 0.3); 85 } 86 87 html { 88 scroll-behavior: smooth; 89 } 90 91 body { 92 font-family: var(--font-body); 93 font-size: var(--text-base); 94 line-height: 1.6; 95 color: var(--color-text); 96 background: var(--color-bg); 97 transition: background var(--transition-slow), color var(--transition-slow); 98 } 99 100 code { 101 font-family: 'SF Mono', Monaco, monospace; 102 font-size: 0.9em; 103 background: var(--color-surface); 104 padding: 0.15em 0.4em; 105 border-radius: 4px; 106 border: 1px solid var(--color-border); 107 } 108} 109 110/* ============================================ 111 LAYOUT LAYER 112 ============================================ */ 113@layer layout { 114 .container { 115 width: min(1100px, 100% - 2rem); 116 margin-inline: auto; 117 } 118 119 .page-wrapper { 120 display: grid; 121 grid-template-rows: auto 1fr auto; 122 min-height: 100vh; 123 } 124 125 /* Reading Progress Bar */ 126 .reading-progress { 127 position: fixed; 128 top: 0; 129 left: 0; 130 height: 3px; 131 background: var(--color-primary); 132 transform-origin: left; 133 z-index: 1000; 134 animation: grow-width linear; 135 animation-timeline: scroll(root); 136 } 137} 138 139/* ============================================ 140 COMPONENTS LAYER 141 ============================================ */ 142@layer components { 143 /* Header */ 144 .site-header { 145 position: sticky; 146 top: 0; 147 z-index: 100; 148 background: var(--color-surface); 149 border-bottom: 1px solid var(--color-border); 150 backdrop-filter: blur(12px); 151 background-color: color-mix(in oklch, var(--color-surface) 85%, transparent); 152 } 153 154 .header-inner { 155 display: flex; 156 align-items: center; 157 gap: var(--space-md); 158 padding: var(--space-sm) 0; 159 } 160 161 .logo { 162 font-size: var(--text-lg); 163 font-weight: 700; 164 color: var(--color-primary); 165 letter-spacing: -0.02em; 166 } 167 168 .site-header nav { 169 margin-left: auto; 170 } 171 172 .site-header nav ul { 173 display: flex; 174 gap: var(--space-md); 175 } 176 177 .site-header nav a { 178 font-size: var(--text-sm); 179 font-weight: 500; 180 color: var(--color-text-muted); 181 padding: 0.25rem 0; 182 border-bottom: 2px solid transparent; 183 transition: color var(--transition-fast), border-color var(--transition-fast); 184 } 185 186 .site-header nav a:hover { 187 color: var(--color-primary); 188 border-bottom-color: var(--color-primary); 189 } 190 191 /* Dark Mode Toggle */ 192 .theme-toggle { 193 position: absolute; 194 opacity: 0; 195 width: 0; 196 height: 0; 197 } 198 199 .theme-toggle-label { 200 cursor: pointer; 201 display: inline-flex; 202 align-items: center; 203 } 204 205 .toggle-track { 206 width: 48px; 207 height: 26px; 208 background: var(--color-border); 209 border-radius: 999px; 210 position: relative; 211 transition: background var(--transition-base); 212 } 213 214 .toggle-thumb { 215 position: absolute; 216 top: 3px; 217 left: 3px; 218 width: 20px; 219 height: 20px; 220 background: var(--color-surface-elevated); 221 border-radius: 50%; 222 transition: transform var(--transition-base), background var(--transition-base); 223 box-shadow: 0 1px 3px var(--shadow-color); 224 } 225 226 :root:has(#theme-toggle:checked) .toggle-track { 227 background: var(--color-primary); 228 } 229 230 :root:has(#theme-toggle:checked) .toggle-thumb { 231 transform: translateX(22px); 232 } 233 234 /* Hero */ 235 .hero { 236 padding: clamp(4rem, 10vh, 8rem) 0; 237 text-align: center; 238 } 239 240 .hero-content { 241 max-width: 700px; 242 margin: 0 auto; 243 } 244 245 .hero h1 { 246 font-size: var(--text-2xl); 247 font-weight: 700; 248 line-height: 1.1; 249 letter-spacing: -0.03em; 250 margin-bottom: var(--space-md); 251 background: linear-gradient(135deg, var(--color-text) 0%, var(--color-primary) 100%); 252 -webkit-background-clip: text; 253 -webkit-text-fill-color: transparent; 254 } 255 256 :root:has(#theme-toggle:checked) .hero h1 { 257 background: linear-gradient(135deg, var(--color-text) 0%, var(--color-primary-light) 100%); 258 -webkit-background-clip: text; 259 -webkit-text-fill-color: transparent; 260 } 261 262 .hero p { 263 font-size: var(--text-lg); 264 color: var(--color-text-muted); 265 margin-bottom: var(--space-lg); 266 max-width: 55ch; 267 margin-inline: auto; 268 } 269 270 /* Buttons */ 271 .button { 272 display: inline-flex; 273 align-items: center; 274 gap: 0.5rem; 275 font-weight: 600; 276 border-radius: 8px; 277 border: none; 278 cursor: pointer; 279 transition: transform var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast); 280 } 281 282 .button-primary { 283 background: var(--color-primary); 284 color: white; 285 padding: 0.875rem 1.75rem; 286 font-size: var(--text-base); 287 box-shadow: 0 4px 14px color-mix(in oklch, var(--color-primary) 30%, transparent); 288 } 289 290 .button-primary:hover { 291 background: var(--color-primary-dark); 292 transform: translateY(-2px); 293 box-shadow: 0 6px 20px color-mix(in oklch, var(--color-primary) 40%, transparent); 294 } 295 296 .button-primary:active { 297 transform: translateY(0) scale(0.98); 298 } 299 300 .button-large { 301 padding: 1rem 2.5rem; 302 font-size: var(--text-lg); 303 } 304 305 .button-arrow { 306 transition: transform var(--transition-fast); 307 } 308 309 .button:hover .button-arrow { 310 transform: translateX(4px); 311 } 312 313 /* Sections */ 314 .section-title { 315 font-size: var(--text-xl); 316 font-weight: 700; 317 text-align: center; 318 margin-bottom: var(--space-xs); 319 } 320 321 .section-lead { 322 text-align: center; 323 color: var(--color-text-muted); 324 max-width: 60ch; 325 margin: 0 auto var(--space-lg); 326 } 327 328 /* Features Grid */ 329 .features { 330 padding: var(--space-xl) 0; 331 background: var(--color-surface); 332 transition: background var(--transition-slow); 333 } 334 335 .features-grid { 336 display: grid; 337 grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); 338 gap: var(--space-md); 339 } 340 341 /* Scoped Feature Cards */ 342 @scope (.feature-card) { 343 :scope { 344 background: var(--color-surface-elevated); 345 border: 1px solid var(--color-border); 346 border-radius: 16px; 347 padding: var(--space-md); 348 transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base); 349 } 350 351 :scope:hover { 352 transform: translateY(-4px); 353 box-shadow: 0 12px 40px var(--shadow-color); 354 border-color: var(--color-primary-light); 355 } 356 357 .feature-icon { 358 font-size: 2.5rem; 359 margin-bottom: var(--space-sm); 360 display: block; 361 } 362 363 h3 { 364 font-size: var(--text-lg); 365 font-weight: 600; 366 margin-bottom: var(--space-xs); 367 } 368 369 p { 370 color: var(--color-text-muted); 371 font-size: var(--text-sm); 372 line-height: 1.6; 373 margin-bottom: var(--space-sm); 374 } 375 376 /* Tooltip within card */ 377 .tooltip-wrapper { 378 position: relative; 379 display: inline-block; 380 } 381 382 .tooltip-trigger { 383 anchor-name: --card-tooltip; 384 background: transparent; 385 border: 1px solid var(--color-border); 386 color: var(--color-primary); 387 padding: 0.5rem 1rem; 388 border-radius: 6px; 389 font-size: var(--text-sm); 390 font-weight: 500; 391 cursor: pointer; 392 transition: all var(--transition-fast); 393 } 394 395 .tooltip-trigger:hover, 396 .tooltip-trigger:focus { 397 background: var(--color-primary); 398 color: white; 399 border-color: var(--color-primary); 400 } 401 402 .tooltip { 403 position: absolute; 404 position-anchor: --card-tooltip; 405 position-area: top; 406 margin-bottom: 8px; 407 408 background: var(--color-text); 409 color: var(--color-bg); 410 padding: 0.5rem 1rem; 411 border-radius: 8px; 412 font-size: var(--text-xs); 413 white-space: nowrap; 414 opacity: 0; 415 pointer-events: none; 416 transition: opacity var(--transition-fast), transform var(--transition-fast); 417 transform: translateY(4px); 418 419 position-try-fallbacks: --tooltip-bottom; 420 } 421 422 @position-try --tooltip-bottom { 423 position-area: bottom; 424 margin-top: 8px; 425 margin-bottom: 0; 426 } 427 428 :scope:has(.tooltip-trigger:hover) .tooltip, 429 :scope:has(.tooltip-trigger:focus) .tooltip { 430 opacity: 1; 431 transform: translateY(0); 432 } 433 } 434 435 /* Showcase */ 436 .showcase { 437 padding: var(--space-xl) 0; 438 } 439 440 .showcase-demo { 441 display: flex; 442 flex-wrap: wrap; 443 justify-content: center; 444 gap: var(--space-md); 445 margin-top: var(--space-lg); 446 } 447 448 .demo-box { 449 background: var(--color-surface); 450 border: 1px solid var(--color-border); 451 border-radius: 16px; 452 padding: var(--space-md); 453 text-align: center; 454 min-width: 180px; 455 transition: border-color var(--transition-fast); 456 } 457 458 .demo-box:hover { 459 border-color: var(--color-primary); 460 } 461 462 .demo-box span { 463 display: block; 464 margin-top: var(--space-sm); 465 font-size: var(--text-sm); 466 font-weight: 500; 467 color: var(--color-text-muted); 468 } 469 470 .pulse-ring { 471 width: 60px; 472 height: 60px; 473 margin: 0 auto; 474 border-radius: 50%; 475 background: var(--color-primary); 476 animation: pulse 2s ease-in-out infinite; 477 } 478 479 .morph-shape { 480 width: 60px; 481 height: 60px; 482 margin: 0 auto; 483 background: var(--color-primary); 484 animation: morph 4s ease-in-out infinite; 485 } 486 487 .gradient-shift { 488 width: 60px; 489 height: 60px; 490 margin: 0 auto; 491 border-radius: 12px; 492 background: linear-gradient(45deg, var(--color-primary), var(--color-primary-dark)); 493 background-size: 200% 200%; 494 animation: gradient-shift 3s ease infinite; 495 } 496 497 /* Contact */ 498 .contact { 499 padding: var(--space-xl) 0; 500 background: var(--color-surface); 501 transition: background var(--transition-slow); 502 } 503 504 .contact-form { 505 max-width: 600px; 506 margin: 0 auto; 507 } 508 509 .form-row { 510 display: flex; 511 gap: var(--space-md); 512 margin-bottom: var(--space-md); 513 } 514 515 .field { 516 flex: 1; 517 margin-bottom: var(--space-md); 518 } 519 520 .field label { 521 display: block; 522 font-size: var(--text-sm); 523 font-weight: 500; 524 margin-bottom: var(--space-xs); 525 color: var(--color-text-muted); 526 } 527 528 .field input, 529 .field textarea { 530 width: 100%; 531 padding: 0.75rem 1rem; 532 background: var(--color-surface-elevated); 533 border: 1px solid var(--color-border); 534 border-radius: 8px; 535 color: var(--color-text); 536 transition: border-color var(--transition-fast), box-shadow var(--transition-fast); 537 } 538 539 .field input:focus, 540 .field textarea:focus { 541 outline: none; 542 border-color: var(--color-primary); 543 box-shadow: 0 0 0 3px color-mix(in oklch, var(--color-primary) 20%, transparent); 544 } 545 546 .field:has(input:invalid:not(:placeholder-shown)) { 547 --color-border: #ef4444; 548 } 549 550 .field:has(input:invalid:not(:placeholder-shown)) label { 551 color: #ef4444; 552 } 553 554 /* Footer */ 555 .site-footer { 556 padding: var(--space-lg) 0; 557 text-align: center; 558 border-top: 1px solid var(--color-border); 559 color: var(--color-text-muted); 560 font-size: var(--text-sm); 561 } 562} 563 564/* ============================================ 565 ANIMATIONS LAYER 566 ============================================ */ 567@layer animations { 568 @keyframes grow-width { 569 from { transform: scaleX(0); } 570 to { transform: scaleX(1); } 571 } 572 573 @keyframes fade-in-up { 574 from { 575 opacity: 0; 576 transform: translateY(50px); 577 } 578 to { 579 opacity: 1; 580 transform: translateY(0); 581 } 582 } 583 584 @keyframes pulse { 585 0%, 100% { transform: scale(1); opacity: 1; } 586 50% { transform: scale(1.1); opacity: 0.7; } 587 } 588 589 @keyframes morph { 590 0%, 100% { border-radius: 50%; transform: rotate(0deg); } 591 50% { border-radius: 4px; transform: rotate(180deg); } 592 } 593 594 @keyframes gradient-shift { 595 0% { background-position: 0% 50%; } 596 50% { background-position: 100% 50%; } 597 100% { background-position: 0% 50%; } 598 } 599 600 /* Scroll-driven reveals */ 601 .reveal { 602 animation: fade-in-up linear both; 603 animation-timeline: view(); 604 animation-range: entry 10% cover 35%; 605 } 606 607 /* Stagger children naturally by their position in DOM */ 608 .features-grid .reveal:nth-child(1) { animation-range: entry 5% cover 30%; } 609 .features-grid .reveal:nth-child(2) { animation-range: entry 10% cover 35%; } 610 .features-grid .reveal:nth-child(3) { animation-range: entry 15% cover 40%; } 611 .features-grid .reveal:nth-child(4) { animation-range: entry 20% cover 45%; } 612 .features-grid .reveal:nth-child(5) { animation-range: entry 25% cover 50%; } 613 .features-grid .reveal:nth-child(6) { animation-range: entry 30% cover 55%; } 614} 615 616/* ============================================ 617 UTILITIES LAYER 618 ============================================ */ 619@layer utilities { 620 .text-center { text-align: center; } 621 .hidden { display: none !important; } 622 .sr-only { 623 position: absolute; 624 width: 1px; 625 height: 1px; 626 padding: 0; 627 margin: -1px; 628 overflow: hidden; 629 clip: rect(0, 0, 0, 0); 630 border: 0; 631 } 632} 633 634/* ============================================ 635 RESPONSIVE 636 ============================================ */ 637@media (max-width: 600px) { 638 .header-inner { 639 flex-wrap: wrap; 640 gap: var(--space-sm); 641 } 642 643 .site-header nav { 644 order: 3; 645 width: 100%; 646 margin-left: 0; 647 } 648 649 .site-header nav ul { 650 justify-content: center; 651 } 652 653 .form-row { 654 flex-direction: column; 655 gap: 0; 656 } 657 658 .hero h1 { 659 font-size: var(--text-xl); 660 } 661}
Part 10: Testing Your Zero-JS Landing Page
1. The Dark Mode Test
Click the toggle switch. The entire page should transition smoothly to dark mode. Check:
- Background colors shift
- Text remains readable
- Borders and shadows adapt
- The toggle thumb slides right
- No flash of unstyled content on reload (browser remembers checkbox state)
2. The Scroll Animation Test
Scroll slowly down the page. Elements should fade in and slide up as they enter the viewport. Check:
- Animations don't play if the element is already visible on load
- Multiple scrolls don't re-trigger animations (thanks to
bothfill-mode) - The reading progress bar grows from left to right
3. The Anchor Positioning Test
Hover over the "Try Tooltip" button in the Features section. The tooltip should appear above the button. Resize your browser so the button is near the top edge. The tooltip should automatically flip below the button (via position-try-fallbacks).
4. The Form Validation Test
Type an invalid email in the contact form. The field border and label should turn red — powered by :has(input:invalid) with zero JavaScript.
5. The Performance Test
Open Chrome DevTools → Performance. Record while scrolling and hovering. You should see:
- Green bars (GPU) for all animations
- No purple bars (layout recalculation) during scroll
- 60fps maintained throughout
Key Takeaways
| Feature | What It Replaces | The Benefit |
|---|---|---|
| CSS Custom Properties | Sass variables, JS theme switching | Runtime theming, dark mode without JS |
| calc/min/max/clamp | Media queries for font sizing | Fluid, responsive values in one line |
| Transitions/Animations | JS animation libraries (for simple effects) | 60fps GPU acceleration, smaller bundles |
| Scroll-driven Animations | Intersection Observer + JS | Native viewport-linked animations |
| Anchor Positioning | getBoundingClientRect() + JS | Automatic collision detection, no coordinate math |
| @layer | !important wars, specificity hacks | Explicit cascade control |
| @scope | BEM naming conventions | Native style boundaries without naming conventions |
| :has() | JS DOM traversal for styling | Parent and previous-sibling selection |
What's Next?
You now have a landing page that handles theming, animation, positioning, and interaction without a single line of JavaScript. This isn't a gimmick — it's how professional sites are being built in 2026.
Module 5 covers Responsive Design & Accessibility: mobile-first breakpoints, responsive images, the picture element, WCAG guidelines, ARIA roles, and manual accessibility testing. We'll make this landing page work flawlessly on a $50 Android phone with a screen reader.
Save your modern-css-landing folder. We're coming back to audit and optimize it.
Quick Reference Cheat Sheet
1/* Dark mode toggle */ 2:root:has(#toggle:checked) { --bg: #000; } 3 4/* Scroll-driven animation */ 5.element { 6 animation: fade-in linear; 7 animation-timeline: view(); 8 animation-range: entry 25% cover 50%; 9} 10 11/* Anchor positioning */ 12.trigger { anchor-name: --tip; } 13.tooltip { 14 position: absolute; 15 position-anchor: --tip; 16 position-area: top; 17 position-try-fallbacks: --bottom; 18} 19 20/* Cascade layers */ 21@layer reset, base, components; 22@layer base { body { font-family: sans-serif; } } 23 24/* Scoped styles */ 25@scope (.card) { h3 { font-size: 1.25rem; } } 26 27/* Parent selection */ 28.card:has(img) { display: grid; } 29li:has(+ .active) { border-bottom: 2px solid blue; } 30 31/* Math functions */ 32width: clamp(300px, 50%, 800px); 33padding: min(5vh, 3rem);
Your landing page runs at 60fps with zero JavaScript. In Module 5, we ensure it works for everyone — regardless of device, connection, or ability.