Each experiment isolates one modern feature — running live, with its source and an honest note on where it works today. Every demo degrades gracefully.
01
Scroll-driven animations
Tie an animation's progress to scroll position (scroll()) or an element's visibility (view()) — running on the compositor, so it stays smooth even when the main thread is busy. Scroll the box.
animation-timelineNewly available
↓ keep scrolling
Compositor-threaded
No scroll listeners
No layout thrash
Declarative ranges
Respects reduced-motion
Falls back gracefully
fin.
scroll() drives the top bar · view() reveals each row
Support: Chromium 115+ and Safari 26 ship scroll() and view() timelines; Firefox is rolling them out. Because they're declared in @supports and CSS, unsupported browsers simply show the static end-state — no JavaScript fallback needed.
View sourcelab/scroll-driven-animations.astro
---export const meta = { title: "Scroll-driven animations", feature: "animation-timeline", baseline: "Newly available", support: "Chromium 115+ and Safari 26 ship scroll() and view() timelines; Firefox is rolling them out. Because they're declared in @supports and CSS, unsupported browsers simply show the static end-state — no JavaScript fallback needed.", description: "Tie an animation's progress to scroll position (scroll()) or an element's visibility (view()) — running on the compositor, so it stays smooth even when the main thread is busy. Scroll the box.", order: 1,};const rows = ["Compositor-threaded", "No scroll listeners", "No layout thrash", "Declarative ranges", "Respects reduced-motion", "Falls back gracefully"];---<div class="sda"> <div class="sda__scroller"> <div class="sda__progress" aria-hidden="true"></div> <div class="sda__inner"> <p class="sda__hint">↓ keep scrolling</p> {rows.map((r, i) => ( <div class="sda__row" style={`--i:${i}`}><span class="sda__dot"></span>{r}</div> ))} <p class="sda__end">fin.</p> </div> </div> <p class="sda__label mono">scroll() drives the top bar · view() reveals each row</p></div><style> .sda { padding: var(--space-l); display: grid; gap: 0.8rem; } .sda__scroller { position: relative; height: 300px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--border); border-radius: var(--radius-l); background: var(--surface); scroll-timeline: --box y; } .sda__progress { position: sticky; top: 0; left: 0; height: 4px; width: 100%; transform-origin: 0 50%; transform: scaleX(0); z-index: 2; background: linear-gradient(90deg, var(--spectrum-1, #8b5cf6), var(--spectrum-3, #45d3e8), var(--spectrum-5, #fbbf24)); } .sda__inner { padding: 1.2rem 1.4rem 2rem; display: grid; gap: 1rem; } .sda__hint, .sda__end { color: var(--ink-faint); text-align: center; margin: 0.4rem 0; } .sda__end { padding-top: 8rem; } .sda__row { display: flex; align-items: center; gap: 0.8rem; font-size: var(--step-1); font-weight: 600; padding: 0.9rem 1.1rem; border-radius: var(--radius-m); background: var(--surface-2); border: 1px solid var(--border); } .sda__dot { width: 10px; height: 10px; border-radius: 50%; background: var(--spectrum-3, #45d3e8); flex: none; } @supports (animation-timeline: scroll()) { @media (prefers-reduced-motion: no-preference) { .sda__progress { animation: sda-grow linear both; animation-timeline: --box; } .sda__row { animation: sda-in linear both; animation-timeline: view(--box); animation-range: entry 5% cover 40%; } } } @keyframes sda-grow { to { transform: scaleX(1); } } @keyframes sda-in { from { opacity: 0; transform: translateX(-24px) scale(0.96); } to { opacity: 1; transform: none; } } .sda__label { font-size: var(--step--1); color: var(--ink-muted); text-align: center; }</style>
02
View Transitions
Snapshot the page, mutate the DOM, and let the browser tween between the two states — including shared elements that morph across layouts. Shuffle the grid, or open a tile.
document.startViewTransition()Baseline
click a tile to expand
Support: Same-document view transitions are Baseline across Chromium, Safari and Firefox. The browser snapshots before/after states and animates the difference; where it's missing, startViewTransition() runs the callback synchronously with no animation, so nothing breaks.
View sourcelab/view-transitions.astro
---export const meta = { title: "View Transitions", feature: "document.startViewTransition()", baseline: "Baseline", support: "Same-document view transitions are Baseline across Chromium, Safari and Firefox. The browser snapshots before/after states and animates the difference; where it's missing, startViewTransition() runs the callback synchronously with no animation, so nothing breaks.", description: "Snapshot the page, mutate the DOM, and let the browser tween between the two states — including shared elements that morph across layouts. Shuffle the grid, or open a tile.", order: 2,};const tiles = [ { id: "a", label: "Nebula", hue: "265" }, { id: "b", label: "Lagoon", hue: "190" }, { id: "c", label: "Meadow", hue: "150" }, { id: "d", label: "Ember", hue: "35" }, { id: "e", label: "Bloom", hue: "350" }, { id: "f", label: "Cobalt", hue: "220" },];---<div class="vt" data-vt> <div class="vt__bar"> <button class="vt__btn" data-shuffle>⤨ Shuffle</button> <span class="vt__note mono">click a tile to expand</span> </div> <div class="vt__grid" data-grid> {tiles.map((t) => ( <button class="vt__tile" data-tile={t.id} style={`--h:${t.hue}; view-transition-name: vt-${t.id};`}> <span>{t.label}</span> </button> ))} </div> <div class="vt__detail" data-detail hidden> <button class="vt__tile vt__tile--big" data-detail-tile></button> <button class="vt__close" data-close>← back to grid</button> </div></div><style> .vt { padding: var(--space-l); display: grid; gap: 1rem; } .vt__bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } .vt__btn { padding: 0.5rem 1rem; border-radius: var(--radius-round); border: 1px solid var(--border-strong); background: var(--surface); color: var(--ink); font-weight: 600; } .vt__btn:hover { border-color: var(--accent); } .vt__note { font-size: var(--step--1); color: var(--ink-muted); } .vt__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.8rem; } /* attribute selector out-specifies the display rules so [hidden] actually hides */ .vt__grid[hidden], .vt__detail[hidden] { display: none; } .vt__tile { aspect-ratio: 4 / 3; border-radius: var(--radius-l); border: none; cursor: pointer; color: #fff; font-weight: 700; font-size: var(--step-1); display: grid; place-items: end start; padding: 0.9rem; background: linear-gradient(150deg, oklch(70% 0.18 var(--h)), oklch(52% 0.16 calc(var(--h) + 40))); box-shadow: var(--shadow-m); } .vt__tile span { text-shadow: 0 1px 8px rgba(0,0,0,0.4); } .vt__detail { display: grid; gap: 1rem; justify-items: start; } .vt__tile--big { width: 100%; aspect-ratio: 21 / 9; font-size: var(--step-4); place-items: center; } .vt__close { padding: 0.5rem 1rem; border-radius: var(--radius-round); border: 1px solid var(--border); background: var(--surface); color: var(--ink); font-weight: 600; } ::view-transition-group(*) { animation-duration: 0.4s; animation-timing-function: var(--ease-emph, cubic-bezier(0.2,0,0,1)); } @media (prefers-reduced-motion: reduce) { ::view-transition-group(*) { animation-duration: 0.001s; } }</style><script> (function () { function bind() { document.querySelectorAll<HTMLElement>("[data-vt]").forEach((root) => { if (root.dataset.bound) return; root.dataset.bound = "1"; const grid = root.querySelector<HTMLElement>("[data-grid]")!; const detail = root.querySelector<HTMLElement>("[data-detail]")!; const bigTile = root.querySelector<HTMLButtonElement>("[data-detail-tile]")!; const closeBtn = root.querySelector<HTMLButtonElement>("[data-close]")!; const run = (fn: () => void) => (document as any).startViewTransition && !matchMedia("(prefers-reduced-motion: reduce)").matches ? (document as any).startViewTransition(fn) : fn(); root.querySelectorAll<HTMLButtonElement>("[data-tile]").forEach((tile) => { tile.addEventListener("click", () => { const id = tile.dataset.tile!; run(() => { tile.style.viewTransitionName = ""; bigTile.style.cssText = tile.style.cssText + `; view-transition-name: vt-${id};`; bigTile.textContent = tile.textContent; bigTile.dataset.from = id; grid.hidden = true; detail.hidden = false; }); // move focus out of the now-hidden grid so keyboard users keep their place closeBtn.focus(); }); }); closeBtn.addEventListener("click", () => { const id = bigTile.dataset.from!; const orig = grid.querySelector<HTMLElement>(`[data-tile="${id}"]`)!; run(() => { bigTile.style.viewTransitionName = ""; orig.style.viewTransitionName = `vt-${id}`; detail.hidden = true; grid.hidden = false; }); orig.focus(); }); root.querySelector<HTMLButtonElement>("[data-shuffle]")!.addEventListener("click", () => { const items = [...grid.children]; for (let i = items.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [items[i], items[j]] = [items[j], items[i]]; } run(() => items.forEach((el) => grid.appendChild(el))); }); }); } document.addEventListener("astro:page-load", bind); if (document.readyState !== "loading") bind(); })();</script>
03
CSS Anchor Positioning
Tether one element to another anywhere in the DOM and position it with anchor() — no wrapper, no JS measurement. The popover stays glued to its button and flips when it runs out of room.
anchor() / position-anchorLimited
Hover or focus a node — its label anchors to it purely in CSS.
North · anchored top
East · anchored right
South · anchored bottom
West · anchored left
Support: Shipping in Chromium 125+. Not yet in Safari or Firefox (in development). This demo uses @supports so browsers without it fall back to normal flow tooltips positioned the old way — still perfectly usable.
View sourcelab/anchor-positioning.astro
---export const meta = { title: "CSS Anchor Positioning", feature: "anchor() / position-anchor", baseline: "Limited", support: "Shipping in Chromium 125+. Not yet in Safari or Firefox (in development). This demo uses @supports so browsers without it fall back to normal flow tooltips positioned the old way — still perfectly usable.", description: "Tether one element to another anywhere in the DOM and position it with anchor() — no wrapper, no JS measurement. The popover stays glued to its button and flips when it runs out of room.", order: 3,};const spots = [ { id: "n", label: "North", side: "top" }, { id: "e", label: "East", side: "right" }, { id: "s", label: "South", side: "bottom" }, { id: "w", label: "West", side: "left" },];---<div class="anc"> <p class="anc__hint text-muted">Hover or focus a node — its label anchors to it purely in CSS.</p> <div class="anc__stage"> {spots.map((s) => ( <div class="anc__pair"> <button class="anc__node" style={`anchor-name: --a-${s.id};`}>{s.label[0]}</button> <div class={`anc__tip anc__tip--${s.side}`} style={`position-anchor: --a-${s.id};`} role="tooltip"> {s.label} · anchored {s.side} </div> </div> ))} </div></div><style> .anc { padding: var(--space-xl) var(--space-l); display: grid; gap: 1.5rem; justify-items: center; } .anc__hint { text-align: center; } .anc__stage { display: flex; flex-wrap: wrap; gap: 3rem; justify-content: center; padding: 2rem 0; } .anc__pair { position: relative; } .anc__node { width: 3.4rem; height: 3.4rem; border-radius: 50%; font-weight: 800; font-size: var(--step-1); color: #fff; background: radial-gradient(circle at 30% 30%, var(--spectrum-3, #45d3e8), var(--spectrum-1, #8b5cf6)); border: none; box-shadow: var(--shadow-m); } .anc__tip { width: max-content; max-width: 14rem; font-size: var(--step--1); font-weight: 600; padding: 0.45rem 0.75rem; border-radius: var(--radius-m); background: var(--ink); color: var(--ink-invert); opacity: 0; visibility: hidden; transition: opacity var(--dur) var(--ease-out); pointer-events: none; z-index: 5; } .anc__pair:hover .anc__tip, .anc__pair:focus-within .anc__tip { opacity: 1; visibility: visible; } /* Fallback (no anchor positioning): absolutely position around the node's own pair */ .anc__tip { position: absolute; left: 50%; top: 50%; translate: -50% -50%; } .anc__tip--top { top: -0.6rem; translate: -50% -100%; } .anc__tip--bottom { top: calc(100% + 0.6rem); translate: -50% 0; } .anc__tip--left { left: -0.6rem; top: 50%; translate: -100% -50%; } .anc__tip--right { left: calc(100% + 0.6rem); top: 50%; translate: 0 -50%; } @supports (anchor-name: --x) { .anc__tip { position: fixed; left: auto; top: auto; translate: 0 0; margin: 0.55rem; } .anc__tip--top { position-area: top; } .anc__tip--bottom { position-area: bottom; } .anc__tip--left { position-area: left; } .anc__tip--right { position-area: right; } .anc__tip { position-try-fallbacks: flip-block, flip-inline; } }</style>
04
The :has() parent selector
Select an element by its descendants or siblings. Here, choosing a plan restyles the whole card, and the summary bar reacts to which options are checked — all in pure CSS.
:has()Baseline
Support: Baseline across Chromium, Safari and Firefox since late 2023. One of the most impactful CSS additions in years — style an element based on what it contains, or on the state of a sibling, with zero JavaScript.
View sourcelab/has-selector.astro
---export const meta = { title: "The :has() parent selector", feature: ":has()", baseline: "Baseline", support: "Baseline across Chromium, Safari and Firefox since late 2023. One of the most impactful CSS additions in years — style an element based on what it contains, or on the state of a sibling, with zero JavaScript.", description: "Select an element by its descendants or siblings. Here, choosing a plan restyles the whole card, and the summary bar reacts to which options are checked — all in pure CSS.", order: 4,};const plans = [ { id: "free", name: "Hobby", price: "Free" }, { id: "pro", name: "Pro", price: "$12/mo" }, { id: "team", name: "Team", price: "$49/mo" },];const addons = ["Priority support", "Custom domain", "Analytics"];---<form class="has" data-has> <fieldset class="has__plans"> <legend class="has__legend">Choose a plan</legend> {plans.map((p, i) => ( <label class="has__plan"> <input type="radio" name="plan" value={p.id} checked={i === 1} /> <span class="has__plan-name">{p.name}</span> <span class="has__plan-price">{p.price}</span> <span class="has__check" aria-hidden="true">✓</span> </label> ))} </fieldset> <fieldset class="has__addons"> <legend class="has__legend">Add-ons</legend> {addons.map((a, i) => ( <label class="has__addon"> <input type="checkbox" checked={i === 0} /> <span>{a}</span> </label> ))} </fieldset> <output class="has__summary">Your selections light up this bar — it turns green only when every add-on is checked.</output></form><style> .has { padding: var(--space-l); display: grid; gap: 1.2rem; } .has__legend { font-weight: 700; margin-bottom: 0.6rem; color: var(--ink); } fieldset { border: none; padding: 0; margin: 0; } .has__plans { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.7rem; } .has__plan { position: relative; display: grid; gap: 0.2rem; padding: 1rem; cursor: pointer; border-radius: var(--radius-l); border: 1.5px solid var(--border); background: var(--surface); transition: border-color var(--dur) var(--ease-out), transform var(--dur) var(--ease-out), box-shadow var(--dur) var(--ease-out); } .has__plan input { position: absolute; opacity: 0; } .has__plan-name { font-weight: 700; } .has__plan-price { color: var(--ink-muted); font-size: var(--step--1); } .has__check { position: absolute; top: 0.7rem; right: 0.8rem; opacity: 0; color: var(--spectrum-4, #34d399); font-weight: 800; } /* the winning trick: style the label because IT HAS a checked input */ .has__plan:has(input:checked) { border-color: var(--accent, #8b5cf6); transform: translateY(-2px); box-shadow: var(--glow); background: color-mix(in oklab, var(--accent, #8b5cf6) 10%, var(--surface)); } .has__plan:has(input:checked) .has__check { opacity: 1; } .has__plan:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; } .has__addons { display: flex; flex-wrap: wrap; gap: 0.6rem; } .has__addon { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.9rem; border-radius: var(--radius-round); border: 1px solid var(--border); background: var(--surface); cursor: pointer; } .has__addon:has(input:checked) { border-color: var(--spectrum-4, #34d399); color: var(--spectrum-4, #34d399); } .has__summary { padding: 0.9rem 1.1rem; border-radius: var(--radius-m); font-size: var(--step--1); border: 1px solid var(--border); background: var(--surface-2); color: var(--ink-muted); transition: background var(--dur) var(--ease-out), color var(--dur) var(--ease-out), border-color var(--dur) var(--ease-out); } /* form-level :has() — all three add-ons checked */ .has:has(.has__addon:nth-of-type(1) input:checked):has(.has__addon:nth-of-type(2) input:checked):has(.has__addon:nth-of-type(3) input:checked) .has__summary { background: color-mix(in oklab, var(--spectrum-4, #34d399) 16%, transparent); border-color: var(--spectrum-4, #34d399); color: var(--ink); }</style>
05
Container Queries
Drag the handle to resize the container. The card inside re-lays-out based on its own width — from stacked, to row, to feature layout — with no viewport media queries.
@containerBaseline
↔ Drag the right edge to resize the container
Field notes
Adaptive by container, not viewport
This card reads its own width and rearranges. Resize me and watch the layout snap between breakpoints.
Support: Baseline across all modern browsers since 2023. Components respond to the size of their container rather than the viewport — so the same card is correct in a sidebar, a grid, or full-width without media-query guesswork.
View sourcelab/container-queries.astro
---export const meta = { title: "Container Queries", feature: "@container", baseline: "Baseline", support: "Baseline across all modern browsers since 2023. Components respond to the size of their container rather than the viewport — so the same card is correct in a sidebar, a grid, or full-width without media-query guesswork.", description: "Drag the handle to resize the container. The card inside re-lays-out based on its own width — from stacked, to row, to feature layout — with no viewport media queries.", order: 5,};---<div class="cq"> <p class="cq__hint text-muted">↔ Drag the right edge to resize the container</p> <div class="cq__resizer"> <article class="cq__card"> <div class="cq__media" aria-hidden="true"></div> <div class="cq__body"> <span class="cq__tag">Field notes</span> <h3>Adaptive by container, not viewport</h3> <p>This card reads its own width and rearranges. Resize me and watch the layout snap between breakpoints.</p> <button class="cq__btn">Read more →</button> </div> </article> </div></div><style> .cq { padding: var(--space-l); display: grid; gap: 0.9rem; } .cq__hint { text-align: center; } .cq__resizer { resize: horizontal; overflow: auto; min-width: 240px; max-width: 100%; width: 100%; padding: 0.5rem; border: 1px dashed var(--border-strong); border-radius: var(--radius-l); background: var(--bg-sunk); container-type: inline-size; container-name: card; } .cq__card { display: grid; gap: 1rem; padding: 1rem; border-radius: var(--radius-l); background: var(--surface); border: 1px solid var(--border); } .cq__media { border-radius: var(--radius-m); min-height: 8rem; background: linear-gradient(135deg, var(--spectrum-1, #8b5cf6), var(--spectrum-3, #45d3e8), var(--spectrum-5, #fbbf24)); } .cq__body { display: grid; gap: 0.5rem; align-content: start; } .cq__tag { font-size: var(--step--1); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--accent, #8b5cf6); } .cq__card h3 { font-size: var(--step-2); margin: 0; } .cq__card p { color: var(--ink-muted); margin: 0; } .cq__btn { justify-self: start; padding: 0.5rem 1rem; border-radius: var(--radius-round); border: 1px solid var(--border-strong); background: var(--surface); color: var(--ink); font-weight: 600; } /* wider than 30rem: media beside body */ @container card (min-width: 30rem) { .cq__card { grid-template-columns: 14rem 1fr; align-items: center; } .cq__media { min-height: 100%; height: 100%; } } /* wider than 46rem: bigger type + feature spacing */ @container card (min-width: 46rem) { .cq__card { grid-template-columns: 20rem 1fr; padding: 1.6rem; gap: 1.6rem; } .cq__card h3 { font-size: var(--step-3); } .cq__media { min-height: 12rem; } }</style>
06
Popover API & <dialog>
Declarative overlays that render in the top layer, above everything, with built-in accessibility. A menu via popover attributes, and a true modal via <dialog>, plus @starting-style entrance animations.
popover / <dialog>Baseline
Quick actions
Support: The Popover API and <dialog> are Baseline across modern browsers. You get top-layer rendering, light-dismiss, focus management and Escape-to-close for free — no library, no focus-trap code.
Drag the hue. Every swatch below is derived live from a single OKLCH base — a lightness ramp, a complementary via relative-color hue math, and tints/shades via color-mix(). No preprocessor, no JS color library.
Support: oklch() and color-mix() are Baseline; relative color syntax (oklch(from …)) is available in all current engines. Perceptually-uniform lightness means ramps built by simply stepping L look evenly spaced — unlike HSL.
View sourcelab/modern-color.astro
---export const meta = { title: "Modern CSS color", feature: "oklch() · color-mix() · relative colors", baseline: "Baseline", support: "oklch() and color-mix() are Baseline; relative color syntax (oklch(from …)) is available in all current engines. Perceptually-uniform lightness means ramps built by simply stepping L look evenly spaced — unlike HSL.", description: "Drag the hue. Every swatch below is derived live from a single OKLCH base — a lightness ramp, a complementary via relative-color hue math, and tints/shades via color-mix(). No preprocessor, no JS color library.", order: 7,};const steps = [0.95, 0.85, 0.72, 0.6, 0.48, 0.36, 0.24];---<div class="col" data-color> <label class="col__control"> <span class="mono">hue <output data-hue>265</output>°</span> <input type="range" min="0" max="360" value="265" data-hue-input aria-label="Base hue" /> </label> <div class="col__group"> <p class="col__label">Lightness ramp — <code class="mono">oklch(L 0.16 h)</code></p> <div class="col__ramp"> {steps.map((l) => <span class="col__sw" style={`background: oklch(${l} 0.16 var(--h));`}></span>)} </div> </div> <div class="col__group"> <p class="col__label">Relative + mix — base, complement <code class="mono">(from base … h+180)</code>, tint, shade</p> <div class="col__ramp"> <span class="col__sw" style="background: var(--base);"></span> <span class="col__sw" style="background: oklch(from var(--base) l c calc(h + 180));"></span> <span class="col__sw" style="background: color-mix(in oklab, var(--base) 35%, white);"></span> <span class="col__sw" style="background: color-mix(in oklab, var(--base) 65%, black);"></span> <span class="col__sw" style="background: color-mix(in oklab, var(--base) 50%, oklch(from var(--base) l c calc(h + 60)));"></span> </div> </div></div><style> .col { --h: 265; --base: oklch(0.62 0.18 var(--h)); padding: var(--space-l); display: grid; gap: 1.3rem; } .col__control { display: grid; gap: 0.5rem; } .col__control span { font-size: var(--step--1); color: var(--ink-muted); } input[type="range"] { width: 100%; accent-color: var(--base); } .col__group { display: grid; gap: 0.5rem; } .col__label { font-size: var(--step--1); color: var(--ink-muted); } .col__ramp { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 0.4rem; height: 4.5rem; } .col__sw { border-radius: var(--radius-m); border: 1px solid color-mix(in oklab, var(--ink) 12%, transparent); box-shadow: var(--shadow-s); }</style><script> (function () { function bind() { document.querySelectorAll<HTMLElement>("[data-color]").forEach((root) => { if (root.dataset.bound) return; root.dataset.bound = "1"; const input = root.querySelector<HTMLInputElement>("[data-hue-input]")!; const out = root.querySelector<HTMLOutputElement>("[data-hue]")!; const update = () => { root.style.setProperty("--h", input.value); root.style.setProperty("--base", `oklch(0.62 0.18 ${input.value})`); out.textContent = input.value; }; input.addEventListener("input", update); update(); }); } document.addEventListener("astro:page-load", bind); if (document.readyState !== "loading") bind(); })();</script>
08
CSS-only carousel
A swipeable, keyboard-scrollable carousel with magnetic snap points. Where supported, the navigation dots and arrows are generated entirely by CSS ::scroll-marker pseudo-elements — no JavaScript at all.
scroll-snap · scroll-markerNewly available
Snap
Swipe
Scroll
Keys
No JS
drag / swipe / arrow-keys · dots are pure CSS where supported
Support: scroll-snap is Baseline everywhere. The CSS carousel pseudo-elements (::scroll-marker, ::scroll-button) that render the dots and arrows with zero JS are newer — Chromium 135+ — and are progressively enhanced here; without them you still get a snapping, swipeable, keyboard-scrollable track.
View sourcelab/scroll-snap.astro
---export const meta = { title: "CSS-only carousel", feature: "scroll-snap · scroll-marker", baseline: "Newly available", support: "scroll-snap is Baseline everywhere. The CSS carousel pseudo-elements (::scroll-marker, ::scroll-button) that render the dots and arrows with zero JS are newer — Chromium 135+ — and are progressively enhanced here; without them you still get a snapping, swipeable, keyboard-scrollable track.", description: "A swipeable, keyboard-scrollable carousel with magnetic snap points. Where supported, the navigation dots and arrows are generated entirely by CSS ::scroll-marker pseudo-elements — no JavaScript at all.", order: 8,};const slides = [ { t: "Snap", h: "265" }, { t: "Swipe", h: "200" }, { t: "Scroll", h: "160" }, { t: "Keys", h: "120" }, { t: "No JS", h: "40" },];---<div class="snap"> <ul class="snap__track" tabindex="0" aria-label="Carousel"> {slides.map((s) => ( <li class="snap__slide" style={`--h:${s.h}`}> <span class="snap__num mono">{s.t}</span> </li> ))} </ul> <p class="snap__hint text-muted mono">drag / swipe / arrow-keys · dots are pure CSS where supported</p></div><style> .snap { padding: var(--space-l); display: grid; gap: 0.9rem; } .snap__track { display: flex; gap: 1rem; list-style: none; margin: 0; padding: 0.25rem; overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth; scrollbar-width: none; anchor-name: --snaptrack; } .snap__track::-webkit-scrollbar { display: none; } .snap__slide { flex: 0 0 min(80%, 22rem); scroll-snap-align: center; height: 12rem; border-radius: var(--radius-l); display: grid; place-items: center; background: linear-gradient(150deg, oklch(72% 0.17 var(--h)), oklch(48% 0.15 calc(var(--h) + 40))); box-shadow: var(--shadow-m); } .snap__num { font-size: var(--step-4); font-weight: 800; color: #fff; text-shadow: 0 2px 12px rgba(0,0,0,0.35); } .snap__hint { text-align: center; font-size: var(--step--1); } /* Progressive: CSS-generated carousel controls (Chromium 135+) */ @supports (scroll-marker-group: after) { .snap__track { scroll-marker-group: after; } .snap__track::scroll-marker-group { display: flex; gap: 0.5rem; justify-content: center; padding-top: 0.9rem; } .snap__slide::scroll-marker { content: ""; width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--border-strong); cursor: pointer; transition: background var(--dur), border-color var(--dur); } .snap__slide::scroll-marker:target-current { background: var(--accent, #8b5cf6); border-color: var(--accent, #8b5cf6); } } @media (prefers-reduced-motion: reduce) { .snap__track { scroll-behavior: auto; } }</style>