This is the intro paragraph — it keeps its default style.
Regular paragraphs get the muted color applied via :not(.intro).
Another regular paragraph, also matched and styled consistently.
MarsBased Tapas
you're not using yet
Finally, a parent selector (and four friends)
Filters elements out. Modern :not() accepts selector lists and full patterns, not just simple selectors.
/* Border on all but last */
.list-item:not(:last-child) {
border-bottom: 1px solid var(--border);
}
/* Stack spacing, skip last */
.stack > *:not(:last-child) {
margin-bottom: 16px;
}
Border on every item except the last — no wrapper trick needed
/* .intro keeps its default style */
.article p:not(.intro) {
color: var(--ink-muted);
font-size: 14px;
}
This is the intro paragraph — it keeps its default style.
Regular paragraphs get the muted color applied via :not(.intro).
Another regular paragraph, also matched and styled consistently.
.intro is excluded — all other p elements get the muted style
/* Only real links get underline */
a:not(.button) {
text-decoration: underline;
color: var(--code-sel);
}
Same element, same tag — .button opt-out removes the underline
/* Hover skips disabled state */
.button:not(:disabled):hover {
background: var(--color-1);
color: var(--ink);
}
Hover only activates on the enabled button — :disabled is excluded
/* List + complex selector */
.content a:not(.button, nav a) {
text-decoration: underline;
color: var(--code-sel);
}
Read the release notes before publishing.
Download kitOnly the content link matches; .button and nav a are excluded in one :not()
Matches any option in a selector list. Great for removing repeated prefixes. Specificity follows the most specific option in the list.
/* Before */
.form input,
.form select,
.form textarea {
font-family: inherit;
border-radius: 6px;
padding: 8px 12px;
}
/* With :is() */
.form :is(input, select, textarea) {
font-family: inherit;
border-radius: 6px;
padding: 8px 12px;
}
One rule styles input, select, and textarea without repeating .form
/* Before */
.btn:hover,
.btn:focus-visible {
background: var(--color-1);
color: var(--ink);
}
/* With :is() */
.btn:is(:hover, :focus-visible) {
background: var(--color-1);
color: var(--ink);
}
Hover or focus-visible share the same state styles
/* Before */
.card h1,
.card h2,
.card h3 {
margin-bottom: 0.5em;
color: var(--ink);
}
/* With :is() */
.card :is(h1, h2, h3) {
margin-bottom: 0.5em;
color: var(--ink);
}
This card uses an h1 — semantically the top of the page.
This one uses h2 — a subsection inside an article.
This uses h3 — same visual result, different semantic level.
Different heading levels, one visual treatment
Matches like :is(), but adds zero specificity. Use it for defaults that should stay easy to override.
/* Without :where() — same specificity */
.btn-primary { background: red; } /* (0,1,0) */
.btn { background: blue; } /* (0,1,0) — cascade wins */
/* With :where() — zero specificity */
.btn-primary { background: red; } /* (0,1,0) — wins */
:where(.btn) { background: blue; } /* (0,0,0) */
The base button rule stays weak, so .btn-primary wins cleanly
Matches an element when a selector matches relative to it. Parents can react to child state, content, and interaction, no JS required.
/* Invalid input highlights parent */
.form-group:has(input:invalid) {
border-color: red;
}
/* Checked label turns bold */
label:has(input:checked) {
font-weight: bold;
}
Parents react to invalid and checked children, no JS
/* Whole card lifts on link hover */
.card:has(a:hover) {
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
/* Image card loses top padding */
.card:has(img) {
padding-top: 0;
}
/* Highlight nav item with current link */
.nav-item:has([aria-current="page"]) {
border-bottom: 2px solid purple;
color: var(--ink);
font-weight: 600;
}
The nav item highlights when its link marks the current page
/* :has() + :is() — focus on any field */
.form-group:has(
:is(input, textarea):focus
) {
border-color: var(--color-1);
box-shadow: 0 0 0 3px
oklch(from var(--color-1)
l c h / 0.25);
}
Click either field — the parent highlights on input or textarea focus
Components that finally mind their own business
Components respond to the space they actually get, not the viewport. Mark a wrapper with container-type, then restyle its children with @container.
.profile-wrapper {
container-type: inline-size; /* or size, normal */
container-name: profile;
}
@container profile (min-width: 350px) {
.card__banner {
flex-direction: row;
}
.card__actions {
flex-direction: row;
}
.card__data {
grid-template-columns: 1fr 1fr;
}
}
Responsive UI without guessing the page layout. The page numbers appear only when the pagination container has room.
.pagination {
container-type: inline-size; /* or size, normal */
container-name: pager;
}
.pagination__pages {
display: none;
}
@container pager (min-width: 500px) {
.pagination__pages {
display: flex;
}
}
Container units are viewport units for components. cqw and cqh resolve against the query container's width and height.
.container {
container-type: size;
}
.title {
font-size: clamp(24px, 8cqw, 56px);
}
.bar--width {
width: 40cqw;
}
.bar--height {
width: 40cqh;
}
Resize me
For when flexbox and magic numbers aren't enough
A grid child can inherit its parent's rows or columns with subgrid. Nested content lines up without fake tables or fixed heights.
.pricing {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: auto repeat(5, auto);
}
.card {
display: grid;
grid-row: span 6;
grid-template-rows: subgrid;
}
.card ul {
display: contents;
}
Build table-like layouts without <table>. Without subgrid, each row sizes its columns independently. With it, every column lines up.
.table {
display: grid;
grid-template-columns:
auto auto auto auto;
}
.row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
Remember lighten($color, 20%)?
CSS can now mix, derive, and interpolate colors directly. Build palettes, gradients, and theme-aware tokens without a preprocessor.
/* Tint — mix with white */
--tint: color-mix(
in oklch,
var(--accent) 75%, white
);
/* Shade — mix with black */
--shade: color-mix(
in oklch,
var(--accent), black 25%
);
/* Blend two colors */
--blend: color-mix(
in oklch,
coral 50%, teal
);
Mix tokens at runtime, in the color space you choose
/* Lightness Chroma Hue */
color: oklch(65% 0.2 270);
/* Same C and h, step L by 15%:
each swatch is the same
perceptual distance apart */
.palette--1 { background: oklch(90% 0.15 290); }
.palette--2 { background: oklch(75% 0.15 290); }
.palette--3 { background: oklch(60% 0.15 290); }
.palette--4 { background: oklch(45% 0.15 290); }
.palette--5 { background: oklch(30% 0.15 290); }
.palette--6 { background: oklch(15% 0.15 290); }
Changing L creates even perceptual steps across the palette
/* Default: oklab,
desaturated middle */
background: linear-gradient(
to right, ...
);
/* sRGB: legacy, muddy middle */
background: linear-gradient(
in srgb to right, ...
);
/* Explicit oklch:
travels the hue circle */
background: linear-gradient(
in oklch to right, ...
);
Same endpoints, different interpolation space
/* Derive variants from one token */
--accent: oklch(55% 0.2 290);
--accent-soft: oklch(from var(--accent)
calc(l + 0.12) c h);
--accent-strong: oklch(from var(--accent)
calc(l - 0.12) c h);
--accent-alpha: oklch(from var(--accent)
l c h / 0.35);
Derived at runtime from one base token
/* Opt in to both schemes */
:root {
color-scheme: light dark;
}
/* Resolves per color-scheme */
.heading {
color: light-dark(
oklch(20% 0.02 260),
oklch(92% 0.02 260)
);
}
.surface {
background: light-dark(
oklch(98% 0.01 260),
oklch(20% 0.02 260)
);
}
A carefully balanced serif with crisp terminals and a generous x-height.
A carefully balanced serif with crisp terminals and a generous x-height.
No media query: values resolve from the active color scheme
Bye-bye, IntersectionObserver
Attach an animation to an element's position in the scrollport. With view(), progress comes from entering and crossing the nearest scrollport.
.item {
animation: wipe-right linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
@keyframes wipe-right {
from {
clip-path: inset(0 100% 0 0);
}
to {
clip-path: inset(0);
}
}
@keyframes circle-in {
from {
clip-path: circle(0% at 50% 50%);
}
to {
clip-path: circle(100% at 50% 50%);
}
}
view() can take block/inline or x/y; use animation-range to tune when it runs
Bind an animation to a scroll container instead of time. The browser maps scroll progress to animation progress.
.container {
overflow-y: auto;
}
.progress-bar {
position: sticky;
top: 0;
transform-origin: left;
animation: grow linear both;
animation-timeline: scroll(nearest);
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
scroll(nearest) uses the closest scroller; use root, self, block/inline, or x/y when needed
Name a scroll timeline once, then attach animations to it from anywhere inside the section. Here, vertical scroll drives a sticky horizontal track.
.horizontal-wrapper {
height: 400dvh;
view-timeline-name: --section-scroll;
view-timeline-axis: block;
}
.sticky {
position: sticky;
top: 0;
height: 100dvh;
overflow: hidden;
}
.track {
display: flex;
width: 400vw;
animation: horizontal-pan linear both;
animation-timeline: --section-scroll;
animation-range: contain 0% contain 100%;
}
@keyframes horizontal-pan {
to {
transform: translateX(-75%);
}
}
contain starts the pan once the sticky section fills the scrollport
The simplest pattern: view() plus opacity and transform keyframes. Each item fades in on entry and fades out on exit.
.item {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% exit 100%;
}
@keyframes reveal {
entry 0% {
opacity: 0;
}
entry 100% {
opacity: 1;
}
exit 0% {
opacity: 1;
transform: translateY(0);
}
exit 100% {
opacity: 0;
transform: translateY(-40px);
}
}
Each contact responds to its own position in the scrollport
Framer Motion? Now optional
Wrap the DOM update in startViewTransition(). That's the API: the browser captures before and after, then crossfades them by default.
button.onclick = () => {
document.startViewTransition(() => {
box.classList.toggle('red')
box.classList.toggle('blue')
})
}
No CSS yet. The default animation is a root crossfade.
Next: name one piece. The old element gets the name before the callback; the new element gets the same unique name inside it.
/* CSS: normal styles for each view */
.card-emoji { font-size: 28px; }
.detail-emoji { font-size: 64px; }
// JS: old snapshot, small emoji in the card
cardEmoji.style.viewTransitionName = 'selected-emoji'
const transition = document.startViewTransition(() => {
list.hidden = true
detail.hidden = false
// New snapshot: large emoji in the detail view
detailEmoji.textContent = cardEmoji.textContent
detailEmoji.style.viewTransitionName = 'selected-emoji'
})
transition.finished.finally(() => {
cardEmoji.style.viewTransitionName = ''
detailEmoji.style.viewTransitionName = ''
})
Same name, two snapshots: small card emoji to large detail emoji.
Once a region is named, you can animate its old/new snapshots with CSS. ::view-transition-image-pair() clips the slide inside the form frame.
/* Name the wrapper so it animates on its own */
.content { view-transition-name: content; }
/* Clip to the box so slides don't spill out */
::view-transition-image-pair(content) {
overflow: hidden;
}
html[data-dir='forward']::view-transition-old(content) {
animation: slide-out-left 500ms ease-out both;
}
html[data-dir='forward']::view-transition-new(content) {
animation: slide-in-right 500ms ease-out;
}
html[data-dir='back']::view-transition-old(content) {
animation: slide-out-right 500ms ease-out both;
}
html[data-dir='back']::view-transition-new(content) {
animation: slide-in-left 500ms ease-out;
}
// JS: direction chooses the old/new keyframes
function transitionToStep(next) {
document.documentElement.dataset.dir =
next > current ? 'forward' : 'back'
document.startViewTransition(() => {
showStep(next)
})
}
Your order number is #12345. A confirmation is on the way.
Back uses the same named region with the opposite keyframes.
Routes use the same pieces. SPAs wrap the render in startViewTransition(). Same-origin MPAs can opt in with @view-transition.
/* MPA: both pages opt in */
@view-transition {
navigation: auto;
}
// SPA: wrap the route render
function navigate(route) {
document.startViewTransition(() => {
render(route)
})
}
/* Shared pieces still need matching names */
.card.is-selected .thumb,
.detail .thumb {
view-transition-name: route-hero;
}
Works in any modern framework. Next.js App Router has native integration.
Questions?