Tailwind v4MIT licensed

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.

Live Demo
Plain CSSTailwind v4
Try it

Nucleo: 44,000+ SVG icons in five families · All Icons Bundle $149

Nucleo · Affiliate link

Get the Nucleo bundle

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 &middot; 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 &mdash; 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 &mdash; 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% &rarr; 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> &mdash; the instant the button is wholly inside the scrollport, never a pixel earlier &mdash; 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% &rarr; 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 &mdash; and to the same finished state for anyone who has asked for reduced motion.</p>
      <p class="mt-[18px] mb-0 text-svgb17-muted">&#9633;</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);
}

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<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.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@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);
}
3Fonts used by this demooptional — skip if you have your own type stack
<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 in setup, not as an @import inside the stylesheet. Tailwind emits user @import rules after its own @layer blocks, and a late @import is 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 @theme with no duration and no iteration count: svgb17-draw linear both. The timeline supplies the time; both is what holds the drawn state after the range.
  • The @supports not block 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 to scroll(nearest block).
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
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 &mdash; 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 &middot; 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 &mdash; 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 &mdash; 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% &rarr; 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> &mdash; the instant the button is wholly inside the scrollport, never a pixel earlier &mdash; 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% &rarr; 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 &mdash; and to the same finished state for anyone who has asked for reduced motion.</p>
      <p class="mt-[18px] mb-0 text-svgb17-muted">&#9633;</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% to contain 55% is where the feel lives.
  • Use scroll() for anything that reports overall progress and view() for anything about a specific element.
  • Keep both in 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 not block 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 both and 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 express not plus 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

ChromeSafariFirefoxEdge
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.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Search CodeFronts

Loading…