17 / 20
All 20 in 20 Tailwind SVG Stroke Buttons
- 01Tailwind SVG Stroke Draw Button Border on Hover
- 02Tailwind Stroke Dashoffset Button Outline Animation
- 03Tailwind Corner Start Perimeter Draw Button
- 04Tailwind Two Path Split Border Draw Button
- 05Tailwind Rounded Rect Stroke Button with rx Radius
- 06Tailwind Dashed Marching Ants Button Border
- 07Tailwind Gradient Stroke Button with SVG linearGradient
- 08Tailwind Stroke Draw Button That Reverses on Mouse Leave
- 09Tailwind Focus Visible Stroke Outline for Keyboard Users
- 10Tailwind Non Scaling Stroke Button That Resizes with Text
- 11Tailwind Stroke Draw Button with pathLength Normalization
- 12Tailwind Dual Layer Stroke Button with Offset Trailing Path
- 13Tailwind Sequential Stroke and Label Fade Button
- 14Tailwind Icon Plus Border Stroke Draw Together
- 15Tailwind Stroke Draw Submit Button Loading State
- 16Tailwind Stroke Draw Button Disabled and Error States
- 17Tailwind Scroll Triggered Button Stroke with animation-timeline
- 18Tailwind Stroke Draw Button Group with Staggered Delay
- 19Tailwind Reduced Motion Fallback for Stroke Buttons
- 20Tailwind Variable Width Stroke Button with Text Length
Tailwind Scroll Triggered Button Stroke with animation-timeline
Scroll-driven strokes with no observer and no listener — the timeline and range are arbitrary properties, [animation-timeline:view()] and [animation-range:entry_100%_contain_55%], which turns out to be the readable form: the two CTAs differ by exactly one class, so you can compare their ranges without opening a stylesheet. The @supports not block stays in CSS and is what makes this safe to ship.
Nucleo: 44,000+ SVG icons in five families · All Icons Bundle $149
Nucleo · Affiliate link
The code
<section class="svgb-17 relative block w-full min-h-svh bg-svgb17-bg text-svgb17-fg font-svgb17-serif">
<input class="peer absolute opacity-0 pointer-events-none" type="checkbox" id="svgb-17-theme">
<label class="absolute z-[6] top-4 right-[22px] grid place-items-center size-11 rounded-full border border-current/20 bg-current/8 backdrop-blur-lg cursor-pointer transition-[background-color,transform] duration-150 hover:bg-current/15 active:scale-95 peer-focus-visible:outline-2 peer-focus-visible:outline-current peer-focus-visible:outline-offset-[3px] peer-checked:[&_svg]:rotate-180" for="svgb-17-theme">
<svg class="size-[18px] transition-transform duration-[400ms] ease-[cubic-bezier(0.2,0.7,0.3,1)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor" stroke="none"/></svg>
<span class="absolute size-px overflow-hidden [clip-path:inset(50%)] whitespace-nowrap">Switch colour scheme</span>
</label>
<div class="h-svh overflow-y-auto overflow-x-hidden [scrollbar-width:thin] [scrollbar-color:var(--color-svgb17-rest)_transparent]">
<div class="sticky z-[4] top-[calc(100svh-76px)] left-[clamp(16px,4vw,40px)] size-14 -mb-14 [&>svg]:w-full [&>svg]:h-full [&>svg]:overflow-visible [&>svg]:-rotate-90 [&_circle]:fill-none" aria-hidden="true">
<svg viewBox="0 0 56 56">
<circle class="stroke-svgb17-rest [stroke-width:2]" cx="28" cy="28" r="25" pathLength="1"/>
<circle class="svgb-17__mfill stroke-svgb17-stroke [stroke-width:2.6] [stroke-linecap:round] [stroke-dasharray:1] [stroke-dashoffset:1] animate-svgb17-read [animation-timeline:scroll(nearest_block)]" cx="28" cy="28" r="25" pathLength="1"/>
</svg>
</div>
<article class="max-w-[62ch] mx-auto px-[clamp(22px,6vw,40px)] pt-[clamp(48px,12vh,120px)] pb-[clamp(160px,34vh,340px)] flex flex-col gap-5">
<p class="m-0 font-svgb17-sans text-[11px] tracking-[0.24em] uppercase text-svgb17-stroke">Craft · Issue 04</p>
<h1 class="m-0 text-[clamp(38px,7vw,68px)] font-normal leading-[1.02] tracking-[-0.03em] text-balance">The line that draws itself</h1>
<p class="m-0 text-[clamp(19px,2.4vw,24px)] leading-[1.5] text-svgb17-fg/82">Scroll-driven animation moved a whole category of interaction out of JavaScript and into the style sheet. What it changed is not the effect — it is who owns the clock.</p>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">For a decade the recipe was fixed: observe the element, add a class, let a transition run. It worked, and it always carried the same three costs — a listener, a layout read, and a state that the browser could not reason about.</p>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">A view timeline replaces all three with a declaration. The element's progress through the scrollport <em>is</em> the animation's time, so scrolling backwards runs it backwards, and nothing has to be told when to start.</p>
<div class="flex flex-wrap items-center gap-4 py-2.5">
<button class="relative w-[min(300px,100%)] h-[62px] grid place-items-center border-0 p-0 rounded-md bg-svgb17-stroke/7 text-inherit font-svgb17-sans text-[15px] font-medium cursor-pointer focus-visible:outline-2 focus-visible:outline-svgb17-stroke focus-visible:outline-offset-4" type="button">
<svg class="absolute inset-0 w-full h-full overflow-visible [&_rect]:fill-none [&_rect]:[stroke-linecap:round]" viewBox="0 0 300 62" aria-hidden="true">
<rect class="stroke-svgb17-rest [stroke-width:1.4]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
<rect class="svgb-17__ink stroke-svgb17-stroke [stroke-width:2.2] [stroke-dasharray:1] [stroke-dashoffset:1] animate-svgb17-draw [animation-timeline:view()] [animation-range:entry_100%_contain_55%]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
</svg>
<span class="relative z-[1]">Read the spec notes</span>
</button>
<span class="font-svgb17-sans text-[11px] tracking-[0.06em] text-svgb17-muted">entry 100% → contain 55%</span>
</div>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">The range is the design decision. Begin at <code>entry 100%</code> — the instant the button is wholly inside the scrollport, never a pixel earlier — and finish at <code>contain 55%</code>, while it still sits well inside the viewport. The whole draw happens where the reader is actually looking.</p>
<blockquote class="my-2.5 ps-[22px] border-s-2 border-svgb17-stroke text-[clamp(20px,2.6vw,26px)] italic leading-[1.4]">A reveal that finishes late reads as a bug, because the reader is already looking at the thing you are still animating.</blockquote>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">Fill mode is the part that catches everyone. Without <code>both</code>, the animation applies only inside its range and the border reverts the moment you scroll past it.</p>
<div class="flex flex-wrap items-center gap-4 py-2.5">
<button class="relative w-[min(300px,100%)] h-[62px] grid place-items-center border-0 p-0 rounded-md bg-svgb17-stroke/7 text-inherit font-svgb17-sans text-[15px] font-medium cursor-pointer focus-visible:outline-2 focus-visible:outline-svgb17-stroke focus-visible:outline-offset-4" type="button">
<svg class="absolute inset-0 w-full h-full overflow-visible [&_rect]:fill-none [&_rect]:[stroke-linecap:round]" viewBox="0 0 300 62" aria-hidden="true">
<rect class="stroke-svgb17-rest [stroke-width:1.4]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
<rect class="svgb-17__ink stroke-svgb17-stroke [stroke-width:2.2] [stroke-dasharray:1] [stroke-dashoffset:1] animate-svgb17-draw [animation-timeline:view()] [animation-range:contain_10%_contain_50%]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
</svg>
<span class="relative z-[1]">Subscribe to Craft</span>
</button>
<span class="font-svgb17-sans text-[11px] tracking-[0.06em] text-svgb17-muted">contain 10% → contain 50%</span>
</div>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">Everything here degrades to a plain, fully drawn border where scroll-driven animation is unavailable — and to the same finished state for anyone who has asked for reduced motion.</p>
<p class="mt-[18px] mb-0 text-svgb17-muted">□</p>
</article>
</div>
</section><section class="svgb-17 relative block w-full min-h-svh bg-svgb17-bg text-svgb17-fg font-svgb17-serif">
<input class="peer absolute opacity-0 pointer-events-none" type="checkbox" id="svgb-17-theme">
<label class="absolute z-[6] top-4 right-[22px] grid place-items-center size-11 rounded-full border border-current/20 bg-current/8 backdrop-blur-lg cursor-pointer transition-[background-color,transform] duration-150 hover:bg-current/15 active:scale-95 peer-focus-visible:outline-2 peer-focus-visible:outline-current peer-focus-visible:outline-offset-[3px] peer-checked:[&_svg]:rotate-180" for="svgb-17-theme">
<svg class="size-[18px] transition-transform duration-[400ms] ease-[cubic-bezier(0.2,0.7,0.3,1)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor" stroke="none"/></svg>
<span class="absolute size-px overflow-hidden [clip-path:inset(50%)] whitespace-nowrap">Switch colour scheme</span>
</label>
<div class="h-svh overflow-y-auto overflow-x-hidden [scrollbar-width:thin] [scrollbar-color:var(--color-svgb17-rest)_transparent]">
<div class="sticky z-[4] top-[calc(100svh-76px)] left-[clamp(16px,4vw,40px)] size-14 -mb-14 [&>svg]:w-full [&>svg]:h-full [&>svg]:overflow-visible [&>svg]:-rotate-90 [&_circle]:fill-none" aria-hidden="true">
<svg viewBox="0 0 56 56">
<circle class="stroke-svgb17-rest [stroke-width:2]" cx="28" cy="28" r="25" pathLength="1"/>
<circle class="svgb-17__mfill stroke-svgb17-stroke [stroke-width:2.6] [stroke-linecap:round] [stroke-dasharray:1] [stroke-dashoffset:1] animate-svgb17-read [animation-timeline:scroll(nearest_block)]" cx="28" cy="28" r="25" pathLength="1"/>
</svg>
</div>
<article class="max-w-[62ch] mx-auto px-[clamp(22px,6vw,40px)] pt-[clamp(48px,12vh,120px)] pb-[clamp(160px,34vh,340px)] flex flex-col gap-5">
<p class="m-0 font-svgb17-sans text-[11px] tracking-[0.24em] uppercase text-svgb17-stroke">Craft · Issue 04</p>
<h1 class="m-0 text-[clamp(38px,7vw,68px)] font-normal leading-[1.02] tracking-[-0.03em] text-balance">The line that draws itself</h1>
<p class="m-0 text-[clamp(19px,2.4vw,24px)] leading-[1.5] text-svgb17-fg/82">Scroll-driven animation moved a whole category of interaction out of JavaScript and into the style sheet. What it changed is not the effect — it is who owns the clock.</p>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">For a decade the recipe was fixed: observe the element, add a class, let a transition run. It worked, and it always carried the same three costs — a listener, a layout read, and a state that the browser could not reason about.</p>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">A view timeline replaces all three with a declaration. The element's progress through the scrollport <em>is</em> the animation's time, so scrolling backwards runs it backwards, and nothing has to be told when to start.</p>
<div class="flex flex-wrap items-center gap-4 py-2.5">
<button class="relative w-[min(300px,100%)] h-[62px] grid place-items-center border-0 p-0 rounded-md bg-svgb17-stroke/7 text-inherit font-svgb17-sans text-[15px] font-medium cursor-pointer focus-visible:outline-2 focus-visible:outline-svgb17-stroke focus-visible:outline-offset-4" type="button">
<svg class="absolute inset-0 w-full h-full overflow-visible [&_rect]:fill-none [&_rect]:[stroke-linecap:round]" viewBox="0 0 300 62" aria-hidden="true">
<rect class="stroke-svgb17-rest [stroke-width:1.4]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
<rect class="svgb-17__ink stroke-svgb17-stroke [stroke-width:2.2] [stroke-dasharray:1] [stroke-dashoffset:1] animate-svgb17-draw [animation-timeline:view()] [animation-range:entry_100%_contain_55%]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
</svg>
<span class="relative z-[1]">Read the spec notes</span>
</button>
<span class="font-svgb17-sans text-[11px] tracking-[0.06em] text-svgb17-muted">entry 100% → contain 55%</span>
</div>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">The range is the design decision. Begin at <code>entry 100%</code> — the instant the button is wholly inside the scrollport, never a pixel earlier — and finish at <code>contain 55%</code>, while it still sits well inside the viewport. The whole draw happens where the reader is actually looking.</p>
<blockquote class="my-2.5 ps-[22px] border-s-2 border-svgb17-stroke text-[clamp(20px,2.6vw,26px)] italic leading-[1.4]">A reveal that finishes late reads as a bug, because the reader is already looking at the thing you are still animating.</blockquote>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">Fill mode is the part that catches everyone. Without <code>both</code>, the animation applies only inside its range and the border reverts the moment you scroll past it.</p>
<div class="flex flex-wrap items-center gap-4 py-2.5">
<button class="relative w-[min(300px,100%)] h-[62px] grid place-items-center border-0 p-0 rounded-md bg-svgb17-stroke/7 text-inherit font-svgb17-sans text-[15px] font-medium cursor-pointer focus-visible:outline-2 focus-visible:outline-svgb17-stroke focus-visible:outline-offset-4" type="button">
<svg class="absolute inset-0 w-full h-full overflow-visible [&_rect]:fill-none [&_rect]:[stroke-linecap:round]" viewBox="0 0 300 62" aria-hidden="true">
<rect class="stroke-svgb17-rest [stroke-width:1.4]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
<rect class="svgb-17__ink stroke-svgb17-stroke [stroke-width:2.2] [stroke-dasharray:1] [stroke-dashoffset:1] animate-svgb17-draw [animation-timeline:view()] [animation-range:contain_10%_contain_50%]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
</svg>
<span class="relative z-[1]">Subscribe to Craft</span>
</button>
<span class="font-svgb17-sans text-[11px] tracking-[0.06em] text-svgb17-muted">contain 10% → contain 50%</span>
</div>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">Everything here degrades to a plain, fully drawn border where scroll-driven animation is unavailable — and to the same finished state for anyone who has asked for reduced motion.</p>
<p class="mt-[18px] mb-0 text-svgb17-muted">□</p>
</article>
</div>
</section>@import "tailwindcss";
@theme {
--color-svgb17-bg: #f6f4ef;
--color-svgb17-bg: oklch(0.968 0.008 85);
--color-svgb17-fg: #17161c;
--color-svgb17-fg: oklch(0.20 0.008 285);
--color-svgb17-muted: #6b6875;
--color-svgb17-muted: oklch(0.555 0.014 285);
--color-svgb17-rest: rgba(23, 22, 28, 0.16);
--color-svgb17-rest: oklch(0.20 0.008 285 / 0.16);
--color-svgb17-stroke: #a8320f;
--color-svgb17-stroke: oklch(0.48 0.17 38);
--font-svgb17-serif: 'Newsreader', ui-serif, Georgia, serif;
--font-svgb17-sans: 'Archivo', system-ui, sans-serif;
/* No duration and no iteration count: a scroll-driven animation takes its
time from the timeline, and 'both' is what holds the end state. */
--animate-svgb17-draw: svgb17-draw linear both;
--animate-svgb17-read: svgb17-read linear both;
}
@keyframes svgb17-draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes svgb17-read {
to {
stroke-dashoffset: 0;
}
}
/* The fallback is the reason this demo is shippable. Engines without
scroll-driven animation get the finished border immediately rather than an
invisible one, and anyone asking for reduced motion gets the same. */
@supports not (animation-timeline: view()) {
.svgb-17__ink {
animation: none;
stroke-dashoffset: 0;
}
.svgb-17__mfill {
animation: none;
stroke-dashoffset: 0.65;
opacity: 0.4;
}
}
@media (prefers-reduced-motion: reduce) {
.svgb-17__ink {
animation: none;
stroke-dashoffset: 0;
}
.svgb-17__mfill {
animation: none;
stroke-dashoffset: 0.65;
opacity: 0.4;
}
}
.svgb-17:has(#svgb-17-theme:checked) {
--color-svgb17-bg: #100f13;
--color-svgb17-bg: oklch(0.18 0.008 285);
--color-svgb17-fg: #f0eef4;
--color-svgb17-fg: oklch(0.96 0.006 285);
--color-svgb17-muted: #918e9b;
--color-svgb17-muted: oklch(0.68 0.014 285);
--color-svgb17-rest: rgba(240, 238, 244, 0.18);
--color-svgb17-rest: oklch(0.96 0.006 285 / 0.18);
--color-svgb17-stroke: #ff9070;
--color-svgb17-stroke: oklch(0.79 0.15 40);
}@import "tailwindcss";
@theme {
--color-svgb17-bg: #f6f4ef;
--color-svgb17-bg: oklch(0.968 0.008 85);
--color-svgb17-fg: #17161c;
--color-svgb17-fg: oklch(0.20 0.008 285);
--color-svgb17-muted: #6b6875;
--color-svgb17-muted: oklch(0.555 0.014 285);
--color-svgb17-rest: rgba(23, 22, 28, 0.16);
--color-svgb17-rest: oklch(0.20 0.008 285 / 0.16);
--color-svgb17-stroke: #a8320f;
--color-svgb17-stroke: oklch(0.48 0.17 38);
--font-svgb17-serif: 'Newsreader', ui-serif, Georgia, serif;
--font-svgb17-sans: 'Archivo', system-ui, sans-serif;
/* No duration and no iteration count: a scroll-driven animation takes its
time from the timeline, and 'both' is what holds the end state. */
--animate-svgb17-draw: svgb17-draw linear both;
--animate-svgb17-read: svgb17-read linear both;
}
@keyframes svgb17-draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes svgb17-read {
to {
stroke-dashoffset: 0;
}
}
/* The fallback is the reason this demo is shippable. Engines without
scroll-driven animation get the finished border immediately rather than an
invisible one, and anyone asking for reduced motion gets the same. */
@supports not (animation-timeline: view()) {
.svgb-17__ink {
animation: none;
stroke-dashoffset: 0;
}
.svgb-17__mfill {
animation: none;
stroke-dashoffset: 0.65;
opacity: 0.4;
}
}
@media (prefers-reduced-motion: reduce) {
.svgb-17__ink {
animation: none;
stroke-dashoffset: 0;
}
.svgb-17__mfill {
animation: none;
stroke-dashoffset: 0.65;
opacity: 0.4;
}
}
.svgb-17:has(#svgb-17-theme:checked) {
--color-svgb17-bg: #100f13;
--color-svgb17-bg: oklch(0.18 0.008 285);
--color-svgb17-fg: #f0eef4;
--color-svgb17-fg: oklch(0.96 0.006 285);
--color-svgb17-muted: #918e9b;
--color-svgb17-muted: oklch(0.68 0.014 285);
--color-svgb17-rest: rgba(240, 238, 244, 0.18);
--color-svgb17-rest: oklch(0.96 0.006 285 / 0.18);
--color-svgb17-stroke: #ff9070;
--color-svgb17-stroke: oklch(0.79 0.15 40);
}Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-svgb17-bg: #f6f4ef;
--color-svgb17-bg: oklch(0.968 0.008 85);
--color-svgb17-fg: #17161c;
--color-svgb17-fg: oklch(0.20 0.008 285);
--color-svgb17-muted: #6b6875;
--color-svgb17-muted: oklch(0.555 0.014 285);
--color-svgb17-rest: rgba(23, 22, 28, 0.16);
--color-svgb17-rest: oklch(0.20 0.008 285 / 0.16);
--color-svgb17-stroke: #a8320f;
--color-svgb17-stroke: oklch(0.48 0.17 38);
--font-svgb17-serif: 'Newsreader', ui-serif, Georgia, serif;
--font-svgb17-sans: 'Archivo', system-ui, sans-serif;
/* No duration and no iteration count: a scroll-driven animation takes its
time from the timeline, and 'both' is what holds the end state. */
--animate-svgb17-draw: svgb17-draw linear both;
--animate-svgb17-read: svgb17-read linear both;
}
@keyframes svgb17-draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes svgb17-read {
to {
stroke-dashoffset: 0;
}
}
/* The fallback is the reason this demo is shippable. Engines without
scroll-driven animation get the finished border immediately rather than an
invisible one, and anyone asking for reduced motion gets the same. */
@supports not (animation-timeline: view()) {
.svgb-17__ink {
animation: none;
stroke-dashoffset: 0;
}
.svgb-17__mfill {
animation: none;
stroke-dashoffset: 0.65;
opacity: 0.4;
}
}
@media (prefers-reduced-motion: reduce) {
.svgb-17__ink {
animation: none;
stroke-dashoffset: 0;
}
.svgb-17__mfill {
animation: none;
stroke-dashoffset: 0.65;
opacity: 0.4;
}
}
.svgb-17:has(#svgb-17-theme:checked) {
--color-svgb17-bg: #100f13;
--color-svgb17-bg: oklch(0.18 0.008 285);
--color-svgb17-fg: #f0eef4;
--color-svgb17-fg: oklch(0.96 0.006 285);
--color-svgb17-muted: #918e9b;
--color-svgb17-muted: oklch(0.68 0.014 285);
--color-svgb17-rest: rgba(240, 238, 244, 0.18);
--color-svgb17-rest: oklch(0.96 0.006 285 / 0.18);
--color-svgb17-stroke: #ff9070;
--color-svgb17-stroke: oklch(0.79 0.15 40);
}@import "tailwindcss";
@theme {
--color-svgb17-bg: #f6f4ef;
--color-svgb17-bg: oklch(0.968 0.008 85);
--color-svgb17-fg: #17161c;
--color-svgb17-fg: oklch(0.20 0.008 285);
--color-svgb17-muted: #6b6875;
--color-svgb17-muted: oklch(0.555 0.014 285);
--color-svgb17-rest: rgba(23, 22, 28, 0.16);
--color-svgb17-rest: oklch(0.20 0.008 285 / 0.16);
--color-svgb17-stroke: #a8320f;
--color-svgb17-stroke: oklch(0.48 0.17 38);
--font-svgb17-serif: 'Newsreader', ui-serif, Georgia, serif;
--font-svgb17-sans: 'Archivo', system-ui, sans-serif;
/* No duration and no iteration count: a scroll-driven animation takes its
time from the timeline, and 'both' is what holds the end state. */
--animate-svgb17-draw: svgb17-draw linear both;
--animate-svgb17-read: svgb17-read linear both;
}
@keyframes svgb17-draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes svgb17-read {
to {
stroke-dashoffset: 0;
}
}
/* The fallback is the reason this demo is shippable. Engines without
scroll-driven animation get the finished border immediately rather than an
invisible one, and anyone asking for reduced motion gets the same. */
@supports not (animation-timeline: view()) {
.svgb-17__ink {
animation: none;
stroke-dashoffset: 0;
}
.svgb-17__mfill {
animation: none;
stroke-dashoffset: 0.65;
opacity: 0.4;
}
}
@media (prefers-reduced-motion: reduce) {
.svgb-17__ink {
animation: none;
stroke-dashoffset: 0;
}
.svgb-17__mfill {
animation: none;
stroke-dashoffset: 0.65;
opacity: 0.4;
}
}
.svgb-17:has(#svgb-17-theme:checked) {
--color-svgb17-bg: #100f13;
--color-svgb17-bg: oklch(0.18 0.008 285);
--color-svgb17-fg: #f0eef4;
--color-svgb17-fg: oklch(0.96 0.006 285);
--color-svgb17-muted: #918e9b;
--color-svgb17-muted: oklch(0.68 0.014 285);
--color-svgb17-rest: rgba(240, 238, 244, 0.18);
--color-svgb17-rest: oklch(0.96 0.006 285 / 0.18);
--color-svgb17-stroke: #ff9070;
--color-svgb17-stroke: oklch(0.79 0.15 40);
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&family=Archivo:wght@400;500;600&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&family=Archivo:wght@400;500;600&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
- The webfonts load as
<link>tags insetup, not as an@importinside the stylesheet. Tailwind emits user@importrules after its own@layerblocks, and a late@importis invalid CSS — the browser drops it and the demo silently falls back to system fonts. - Timelines and ranges are arbitrary properties —
[animation-timeline:view()]and[animation-range:entry_100%_contain_55%]— because scroll-driven animation has no utility namespace and the values are functional syntax, not scale steps. - That turns out to be the readable form: the two CTAs differ by one class,
[animation-range:contain_10%_contain_50%]against[animation-range:entry_100%_contain_55%], so you can compare the ranges without opening a stylesheet. - The reading ring uses
[animation-timeline:scroll(nearest_block)]on the same kind of path. A scroll timeline asks how far through the scroller you are; a view timeline asks how far this element has travelled — two questions, one class apart. - The animations are declared in
@themewith no duration and no iteration count:svgb17-draw linear both. The timeline supplies the time;bothis what holds the drawn state after the range. - The
@supports notblock cannot be a utility — a feature query is not a variant — so it stays in CSS with the two selectors it needs, and it is what makes the demo safe to ship today. - The underscore escape carries the functional values through intact:
scroll(nearest_block)compiles toscroll(nearest block).
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Scroll Triggered Button Stroke with animation-timeline
Source: https://codefronts.com/components/tailwind-svg-stroke-buttons/tailwind-scroll-triggered-button-stroke-with-animation-timeline/
Scroll-driven strokes with no observer and no listener — the timeline and range are arbitrary properties, [animation-timeline:view()] and [animation-range:entry_100%_contain_55%], which turns out to be the readable form: the two CTAs differ by exactly one class, so you can compare their ranges without opening a stylesheet. The @supports not block stays in CSS and is what makes this safe to ship.
## HTML
```html
<section class="svgb-17 relative block w-full min-h-svh bg-svgb17-bg text-svgb17-fg font-svgb17-serif">
<input class="peer absolute opacity-0 pointer-events-none" type="checkbox" id="svgb-17-theme">
<label class="absolute z-[6] top-4 right-[22px] grid place-items-center size-11 rounded-full border border-current/20 bg-current/8 backdrop-blur-lg cursor-pointer transition-[background-color,transform] duration-150 hover:bg-current/15 active:scale-95 peer-focus-visible:outline-2 peer-focus-visible:outline-current peer-focus-visible:outline-offset-[3px] peer-checked:[&_svg]:rotate-180" for="svgb-17-theme">
<svg class="size-[18px] transition-transform duration-[400ms] ease-[cubic-bezier(0.2,0.7,0.3,1)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor" stroke="none"/></svg>
<span class="absolute size-px overflow-hidden [clip-path:inset(50%)] whitespace-nowrap">Switch colour scheme</span>
</label>
<div class="h-svh overflow-y-auto overflow-x-hidden [scrollbar-width:thin] [scrollbar-color:var(--color-svgb17-rest)_transparent]">
<div class="sticky z-[4] top-[calc(100svh-76px)] left-[clamp(16px,4vw,40px)] size-14 -mb-14 [&>svg]:w-full [&>svg]:h-full [&>svg]:overflow-visible [&>svg]:-rotate-90 [&_circle]:fill-none" aria-hidden="true">
<svg viewBox="0 0 56 56">
<circle class="stroke-svgb17-rest [stroke-width:2]" cx="28" cy="28" r="25" pathLength="1"/>
<circle class="svgb-17__mfill stroke-svgb17-stroke [stroke-width:2.6] [stroke-linecap:round] [stroke-dasharray:1] [stroke-dashoffset:1] animate-svgb17-read [animation-timeline:scroll(nearest_block)]" cx="28" cy="28" r="25" pathLength="1"/>
</svg>
</div>
<article class="max-w-[62ch] mx-auto px-[clamp(22px,6vw,40px)] pt-[clamp(48px,12vh,120px)] pb-[clamp(160px,34vh,340px)] flex flex-col gap-5">
<p class="m-0 font-svgb17-sans text-[11px] tracking-[0.24em] uppercase text-svgb17-stroke">Craft · Issue 04</p>
<h1 class="m-0 text-[clamp(38px,7vw,68px)] font-normal leading-[1.02] tracking-[-0.03em] text-balance">The line that draws itself</h1>
<p class="m-0 text-[clamp(19px,2.4vw,24px)] leading-[1.5] text-svgb17-fg/82">Scroll-driven animation moved a whole category of interaction out of JavaScript and into the style sheet. What it changed is not the effect — it is who owns the clock.</p>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">For a decade the recipe was fixed: observe the element, add a class, let a transition run. It worked, and it always carried the same three costs — a listener, a layout read, and a state that the browser could not reason about.</p>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">A view timeline replaces all three with a declaration. The element's progress through the scrollport <em>is</em> the animation's time, so scrolling backwards runs it backwards, and nothing has to be told when to start.</p>
<div class="flex flex-wrap items-center gap-4 py-2.5">
<button class="relative w-[min(300px,100%)] h-[62px] grid place-items-center border-0 p-0 rounded-md bg-svgb17-stroke/7 text-inherit font-svgb17-sans text-[15px] font-medium cursor-pointer focus-visible:outline-2 focus-visible:outline-svgb17-stroke focus-visible:outline-offset-4" type="button">
<svg class="absolute inset-0 w-full h-full overflow-visible [&_rect]:fill-none [&_rect]:[stroke-linecap:round]" viewBox="0 0 300 62" aria-hidden="true">
<rect class="stroke-svgb17-rest [stroke-width:1.4]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
<rect class="svgb-17__ink stroke-svgb17-stroke [stroke-width:2.2] [stroke-dasharray:1] [stroke-dashoffset:1] animate-svgb17-draw [animation-timeline:view()] [animation-range:entry_100%_contain_55%]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
</svg>
<span class="relative z-[1]">Read the spec notes</span>
</button>
<span class="font-svgb17-sans text-[11px] tracking-[0.06em] text-svgb17-muted">entry 100% → contain 55%</span>
</div>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">The range is the design decision. Begin at <code>entry 100%</code> — the instant the button is wholly inside the scrollport, never a pixel earlier — and finish at <code>contain 55%</code>, while it still sits well inside the viewport. The whole draw happens where the reader is actually looking.</p>
<blockquote class="my-2.5 ps-[22px] border-s-2 border-svgb17-stroke text-[clamp(20px,2.6vw,26px)] italic leading-[1.4]">A reveal that finishes late reads as a bug, because the reader is already looking at the thing you are still animating.</blockquote>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">Fill mode is the part that catches everyone. Without <code>both</code>, the animation applies only inside its range and the border reverts the moment you scroll past it.</p>
<div class="flex flex-wrap items-center gap-4 py-2.5">
<button class="relative w-[min(300px,100%)] h-[62px] grid place-items-center border-0 p-0 rounded-md bg-svgb17-stroke/7 text-inherit font-svgb17-sans text-[15px] font-medium cursor-pointer focus-visible:outline-2 focus-visible:outline-svgb17-stroke focus-visible:outline-offset-4" type="button">
<svg class="absolute inset-0 w-full h-full overflow-visible [&_rect]:fill-none [&_rect]:[stroke-linecap:round]" viewBox="0 0 300 62" aria-hidden="true">
<rect class="stroke-svgb17-rest [stroke-width:1.4]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
<rect class="svgb-17__ink stroke-svgb17-stroke [stroke-width:2.2] [stroke-dasharray:1] [stroke-dashoffset:1] animate-svgb17-draw [animation-timeline:view()] [animation-range:contain_10%_contain_50%]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
</svg>
<span class="relative z-[1]">Subscribe to Craft</span>
</button>
<span class="font-svgb17-sans text-[11px] tracking-[0.06em] text-svgb17-muted">contain 10% → contain 50%</span>
</div>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">Everything here degrades to a plain, fully drawn border where scroll-driven animation is unavailable — and to the same finished state for anyone who has asked for reduced motion.</p>
<p class="mt-[18px] mb-0 text-svgb17-muted">□</p>
</article>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-svgb17-bg: #f6f4ef;
--color-svgb17-bg: oklch(0.968 0.008 85);
--color-svgb17-fg: #17161c;
--color-svgb17-fg: oklch(0.20 0.008 285);
--color-svgb17-muted: #6b6875;
--color-svgb17-muted: oklch(0.555 0.014 285);
--color-svgb17-rest: rgba(23, 22, 28, 0.16);
--color-svgb17-rest: oklch(0.20 0.008 285 / 0.16);
--color-svgb17-stroke: #a8320f;
--color-svgb17-stroke: oklch(0.48 0.17 38);
--font-svgb17-serif: 'Newsreader', ui-serif, Georgia, serif;
--font-svgb17-sans: 'Archivo', system-ui, sans-serif;
/* No duration and no iteration count: a scroll-driven animation takes its
time from the timeline, and 'both' is what holds the end state. */
--animate-svgb17-draw: svgb17-draw linear both;
--animate-svgb17-read: svgb17-read linear both;
}
@keyframes svgb17-draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes svgb17-read {
to {
stroke-dashoffset: 0;
}
}
/* The fallback is the reason this demo is shippable. Engines without
scroll-driven animation get the finished border immediately rather than an
invisible one, and anyone asking for reduced motion gets the same. */
@supports not (animation-timeline: view()) {
.svgb-17__ink {
animation: none;
stroke-dashoffset: 0;
}
.svgb-17__mfill {
animation: none;
stroke-dashoffset: 0.65;
opacity: 0.4;
}
}
@media (prefers-reduced-motion: reduce) {
.svgb-17__ink {
animation: none;
stroke-dashoffset: 0;
}
.svgb-17__mfill {
animation: none;
stroke-dashoffset: 0.65;
opacity: 0.4;
}
}
.svgb-17:has(#svgb-17-theme:checked) {
--color-svgb17-bg: #100f13;
--color-svgb17-bg: oklch(0.18 0.008 285);
--color-svgb17-fg: #f0eef4;
--color-svgb17-fg: oklch(0.96 0.006 285);
--color-svgb17-muted: #918e9b;
--color-svgb17-muted: oklch(0.68 0.014 285);
--color-svgb17-rest: rgba(240, 238, 244, 0.18);
--color-svgb17-rest: oklch(0.96 0.006 285 / 0.18);
--color-svgb17-stroke: #ff9070;
--color-svgb17-stroke: oklch(0.79 0.15 40);
}
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Scroll Triggered Button Stroke with animation-timeline
Source: https://codefronts.com/components/tailwind-svg-stroke-buttons/tailwind-scroll-triggered-button-stroke-with-animation-timeline/
Scroll-driven strokes with no observer and no listener — the timeline and range are arbitrary properties, [animation-timeline:view()] and [animation-range:entry_100%_contain_55%], which turns out to be the readable form: the two CTAs differ by exactly one class, so you can compare their ranges without opening a stylesheet. The @supports not block stays in CSS and is what makes this safe to ship.
## HTML
```html
<section class="svgb-17 relative block w-full min-h-svh bg-svgb17-bg text-svgb17-fg font-svgb17-serif">
<input class="peer absolute opacity-0 pointer-events-none" type="checkbox" id="svgb-17-theme">
<label class="absolute z-[6] top-4 right-[22px] grid place-items-center size-11 rounded-full border border-current/20 bg-current/8 backdrop-blur-lg cursor-pointer transition-[background-color,transform] duration-150 hover:bg-current/15 active:scale-95 peer-focus-visible:outline-2 peer-focus-visible:outline-current peer-focus-visible:outline-offset-[3px] peer-checked:[&_svg]:rotate-180" for="svgb-17-theme">
<svg class="size-[18px] transition-transform duration-[400ms] ease-[cubic-bezier(0.2,0.7,0.3,1)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor" stroke="none"/></svg>
<span class="absolute size-px overflow-hidden [clip-path:inset(50%)] whitespace-nowrap">Switch colour scheme</span>
</label>
<div class="h-svh overflow-y-auto overflow-x-hidden [scrollbar-width:thin] [scrollbar-color:var(--color-svgb17-rest)_transparent]">
<div class="sticky z-[4] top-[calc(100svh-76px)] left-[clamp(16px,4vw,40px)] size-14 -mb-14 [&>svg]:w-full [&>svg]:h-full [&>svg]:overflow-visible [&>svg]:-rotate-90 [&_circle]:fill-none" aria-hidden="true">
<svg viewBox="0 0 56 56">
<circle class="stroke-svgb17-rest [stroke-width:2]" cx="28" cy="28" r="25" pathLength="1"/>
<circle class="svgb-17__mfill stroke-svgb17-stroke [stroke-width:2.6] [stroke-linecap:round] [stroke-dasharray:1] [stroke-dashoffset:1] animate-svgb17-read [animation-timeline:scroll(nearest_block)]" cx="28" cy="28" r="25" pathLength="1"/>
</svg>
</div>
<article class="max-w-[62ch] mx-auto px-[clamp(22px,6vw,40px)] pt-[clamp(48px,12vh,120px)] pb-[clamp(160px,34vh,340px)] flex flex-col gap-5">
<p class="m-0 font-svgb17-sans text-[11px] tracking-[0.24em] uppercase text-svgb17-stroke">Craft · Issue 04</p>
<h1 class="m-0 text-[clamp(38px,7vw,68px)] font-normal leading-[1.02] tracking-[-0.03em] text-balance">The line that draws itself</h1>
<p class="m-0 text-[clamp(19px,2.4vw,24px)] leading-[1.5] text-svgb17-fg/82">Scroll-driven animation moved a whole category of interaction out of JavaScript and into the style sheet. What it changed is not the effect — it is who owns the clock.</p>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">For a decade the recipe was fixed: observe the element, add a class, let a transition run. It worked, and it always carried the same three costs — a listener, a layout read, and a state that the browser could not reason about.</p>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">A view timeline replaces all three with a declaration. The element's progress through the scrollport <em>is</em> the animation's time, so scrolling backwards runs it backwards, and nothing has to be told when to start.</p>
<div class="flex flex-wrap items-center gap-4 py-2.5">
<button class="relative w-[min(300px,100%)] h-[62px] grid place-items-center border-0 p-0 rounded-md bg-svgb17-stroke/7 text-inherit font-svgb17-sans text-[15px] font-medium cursor-pointer focus-visible:outline-2 focus-visible:outline-svgb17-stroke focus-visible:outline-offset-4" type="button">
<svg class="absolute inset-0 w-full h-full overflow-visible [&_rect]:fill-none [&_rect]:[stroke-linecap:round]" viewBox="0 0 300 62" aria-hidden="true">
<rect class="stroke-svgb17-rest [stroke-width:1.4]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
<rect class="svgb-17__ink stroke-svgb17-stroke [stroke-width:2.2] [stroke-dasharray:1] [stroke-dashoffset:1] animate-svgb17-draw [animation-timeline:view()] [animation-range:entry_100%_contain_55%]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
</svg>
<span class="relative z-[1]">Read the spec notes</span>
</button>
<span class="font-svgb17-sans text-[11px] tracking-[0.06em] text-svgb17-muted">entry 100% → contain 55%</span>
</div>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">The range is the design decision. Begin at <code>entry 100%</code> — the instant the button is wholly inside the scrollport, never a pixel earlier — and finish at <code>contain 55%</code>, while it still sits well inside the viewport. The whole draw happens where the reader is actually looking.</p>
<blockquote class="my-2.5 ps-[22px] border-s-2 border-svgb17-stroke text-[clamp(20px,2.6vw,26px)] italic leading-[1.4]">A reveal that finishes late reads as a bug, because the reader is already looking at the thing you are still animating.</blockquote>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">Fill mode is the part that catches everyone. Without <code>both</code>, the animation applies only inside its range and the border reverts the moment you scroll past it.</p>
<div class="flex flex-wrap items-center gap-4 py-2.5">
<button class="relative w-[min(300px,100%)] h-[62px] grid place-items-center border-0 p-0 rounded-md bg-svgb17-stroke/7 text-inherit font-svgb17-sans text-[15px] font-medium cursor-pointer focus-visible:outline-2 focus-visible:outline-svgb17-stroke focus-visible:outline-offset-4" type="button">
<svg class="absolute inset-0 w-full h-full overflow-visible [&_rect]:fill-none [&_rect]:[stroke-linecap:round]" viewBox="0 0 300 62" aria-hidden="true">
<rect class="stroke-svgb17-rest [stroke-width:1.4]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
<rect class="svgb-17__ink stroke-svgb17-stroke [stroke-width:2.2] [stroke-dasharray:1] [stroke-dashoffset:1] animate-svgb17-draw [animation-timeline:view()] [animation-range:contain_10%_contain_50%]" x="2" y="2" width="296" height="58" rx="6" pathLength="1"/>
</svg>
<span class="relative z-[1]">Subscribe to Craft</span>
</button>
<span class="font-svgb17-sans text-[11px] tracking-[0.06em] text-svgb17-muted">contain 10% → contain 50%</span>
</div>
<p class="m-0 text-lg leading-[1.66] text-pretty [&_code]:font-mono [&_code]:text-[0.86em] [&_code]:px-[5px] [&_code]:py-px [&_code]:rounded [&_code]:bg-svgb17-fg/7">Everything here degrades to a plain, fully drawn border where scroll-driven animation is unavailable — and to the same finished state for anyone who has asked for reduced motion.</p>
<p class="mt-[18px] mb-0 text-svgb17-muted">□</p>
</article>
</div>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-svgb17-bg: #f6f4ef;
--color-svgb17-bg: oklch(0.968 0.008 85);
--color-svgb17-fg: #17161c;
--color-svgb17-fg: oklch(0.20 0.008 285);
--color-svgb17-muted: #6b6875;
--color-svgb17-muted: oklch(0.555 0.014 285);
--color-svgb17-rest: rgba(23, 22, 28, 0.16);
--color-svgb17-rest: oklch(0.20 0.008 285 / 0.16);
--color-svgb17-stroke: #a8320f;
--color-svgb17-stroke: oklch(0.48 0.17 38);
--font-svgb17-serif: 'Newsreader', ui-serif, Georgia, serif;
--font-svgb17-sans: 'Archivo', system-ui, sans-serif;
/* No duration and no iteration count: a scroll-driven animation takes its
time from the timeline, and 'both' is what holds the end state. */
--animate-svgb17-draw: svgb17-draw linear both;
--animate-svgb17-read: svgb17-read linear both;
}
@keyframes svgb17-draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes svgb17-read {
to {
stroke-dashoffset: 0;
}
}
/* The fallback is the reason this demo is shippable. Engines without
scroll-driven animation get the finished border immediately rather than an
invisible one, and anyone asking for reduced motion gets the same. */
@supports not (animation-timeline: view()) {
.svgb-17__ink {
animation: none;
stroke-dashoffset: 0;
}
.svgb-17__mfill {
animation: none;
stroke-dashoffset: 0.65;
opacity: 0.4;
}
}
@media (prefers-reduced-motion: reduce) {
.svgb-17__ink {
animation: none;
stroke-dashoffset: 0;
}
.svgb-17__mfill {
animation: none;
stroke-dashoffset: 0.65;
opacity: 0.4;
}
}
.svgb-17:has(#svgb-17-theme:checked) {
--color-svgb17-bg: #100f13;
--color-svgb17-bg: oklch(0.18 0.008 285);
--color-svgb17-fg: #f0eef4;
--color-svgb17-fg: oklch(0.96 0.006 285);
--color-svgb17-muted: #918e9b;
--color-svgb17-muted: oklch(0.68 0.014 285);
--color-svgb17-rest: rgba(240, 238, 244, 0.18);
--color-svgb17-rest: oklch(0.96 0.006 285 / 0.18);
--color-svgb17-stroke: #ff9070;
--color-svgb17-stroke: oklch(0.79 0.15 40);
}
```How this works
The animation declares no time at all. A scroll-driven animation's progress comes from the timeline, so the theme entry omits duration and iteration count entirely:
@theme { --animate-svgb17-draw: svgb17-draw linear both; }both is doing real work here: it holds the final state after the range ends, so a CTA you have scrolled past stays drawn instead of snapping back.
Two timelines, two different questions. A view timeline measures how far an element has travelled through the viewport; a scroll timeline measures how far through a scroller you are. In the markup they are one class apart:
class="animate-svgb17-draw [animation-timeline:view()]
[animation-range:entry_100%_contain_55%]"
class="animate-svgb17-draw [animation-timeline:scroll(nearest_block)]"Why arbitrary properties are the right form. These values are functions, not steps on a scale — there is no sensible timeline-* namespace to invent. Written inline, the difference between the two CTAs' ranges is visible in the diff, which is exactly what you want when tuning a range that only makes sense while scrolling.
The feature query cannot be a variant. @supports not (animation-timeline: view()) has no utility form, so it stays in CSS with the two selectors it needs. It hands unsupported engines the drawn border immediately — the difference between a progressive enhancement and an invisible button.
Undrawing is free. Because progress is tied to position rather than time, scrolling back up runs the draw in reverse with no extra code. That is the thing an IntersectionObserver implementation cannot do without state.
Make it yours
- Tune the range values first, not the keyframes —
entry 100%tocontain 55%is where the feel lives. - Use
scroll()for anything that reports overall progress andview()for anything about a specific element. - Keep
bothin the animation shorthand, or elements will pop back to undrawn once out of range. - Add a second range to the ring for a two-stage progress read; the timeline stays the same.
- Delete the
@supports notblock only if you have measured your audience — it is the difference between an enhancement and a broken CTA.
Gotchas — read before shipping
- Scroll-driven animations need a duration-less animation. Leave a duration in and the timeline is ignored in some engines and fights it in others.
- Omit
bothand the drawn state is discarded the moment the range ends — the most common bug in this whole technique. - Functional values need the underscore escape for spaces:
scroll(nearest_block), and a literal space ends the class. - A feature query is not a variant.
supports-[...]exists for simple property checks but cannot expressnotplus a functional value cleanly — write the CSS. view()ranges are relative to the element's own travel, so a tall element and a short one need different ranges to feel the same.- Firefox support arrived late; without the fallback block, users on older builds get buttons with no visible border at all.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 115+ | 26+ | 144+ | 115+ |
Scroll-driven animation is the narrowest feature in the collection: Chrome 115, and considerably later in Safari and Firefox. That is precisely why the @supports not block exists — every unsupported engine gets the finished border immediately, so the page is never worse than static.