Tailwind v4MIT licensed

Tailwind Variable Width Stroke Button with Text Length

Six labels from three characters to thirty-five, each deriving its stroke weight, dash rhythm and draw duration from one inline number. Hover transitions the dash pattern rather than the offset — hover:[--svgb-20-duty:1] closes every gap until the measure becomes a solid border, a state change no dash-offset draw can express. Both @property blocks stay in CSS, and they are the load-bearing part.

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-20 relative block w-full min-h-svh bg-svgb20-bg text-svgb20-fg font-svgb20-sans">
  <input class="peer absolute opacity-0 pointer-events-none" type="checkbox" id="svgb-20-theme">
  <label class="absolute z-[5] top-4 right-4 grid place-items-center size-11 rounded-sm border border-current/22 bg-current/7 cursor-pointer transition-[background-color,transform] duration-150 hover:bg-current/14 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-20-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="min-h-svh grid place-items-center p-[clamp(22px,5vw,68px)]">
    <div class="w-full max-w-225 flex flex-col gap-[clamp(20px,3vw,32px)]">
      <div class="flex flex-wrap items-end justify-between gap-3.5 pb-4 border-b border-svgb20-rest">
        <h1 class="m-0 text-[clamp(38px,7vw,82px)] font-bold [font-stretch:118%] leading-[0.88] tracking-[-0.045em] uppercase">Weight<br>Ladder</h1>
        <p class="m-0 font-svgb20-mono text-[11px] tracking-[0.02em] text-svgb20-muted">stroke-width: clamp(1.5px, 1.1px + chars &times; 0.055px, 3px)</p>
      </div>

      <ol class="list-none m-0 p-0 flex flex-col gap-[clamp(12px,2vw,18px)]">
        <li class="grid grid-cols-[34px_1fr_54px] gap-[clamp(12px,2.5vw,22px)] items-center">
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted">03</span>
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px]" type="button" data-chars="3" style="--svgb-20-chars: 3">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:clamp(1.5px,calc(1.1px+var(--svgb-20-chars)*0.055px),3px)]" data-chars="3" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Buy</span>
          </button>
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted text-end">1.5px</span>
        </li>
        <li class="grid grid-cols-[34px_1fr_54px] gap-[clamp(12px,2.5vw,22px)] items-center">
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted">08</span>
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px]" type="button" data-chars="8" style="--svgb-20-chars: 8">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:clamp(1.5px,calc(1.1px+var(--svgb-20-chars)*0.055px),3px)]" data-chars="8" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Add note</span>
          </button>
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted text-end">1.5px</span>
        </li>
        <li class="grid grid-cols-[34px_1fr_54px] gap-[clamp(12px,2.5vw,22px)] items-center">
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted">13</span>
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px]" type="button" data-chars="13" style="--svgb-20-chars: 13">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:clamp(1.5px,calc(1.1px+var(--svgb-20-chars)*0.055px),3px)]" data-chars="13" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Export as CSV</span>
          </button>
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted text-end">1.8px</span>
        </li>
        <li class="grid grid-cols-[34px_1fr_54px] gap-[clamp(12px,2.5vw,22px)] items-center">
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted">22</span>
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px]" type="button" data-chars="22" style="--svgb-20-chars: 22">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:clamp(1.5px,calc(1.1px+var(--svgb-20-chars)*0.055px),3px)]" data-chars="22" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Schedule for next week</span>
          </button>
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted text-end">2.3px</span>
        </li>
        <li class="grid grid-cols-[34px_1fr_54px] gap-[clamp(12px,2.5vw,22px)] items-center">
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted">35</span>
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px]" type="button" data-chars="35" style="--svgb-20-chars: 35">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:clamp(1.5px,calc(1.1px+var(--svgb-20-chars)*0.055px),3px)]" data-chars="35" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Duplicate workspace and invite team</span>
          </button>
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted text-end">3.0px</span>
        </li>
      </ol>

      <div class="flex flex-col gap-3 pt-[18px] border-t border-svgb20-rest">
        <span class="font-svgb20-mono text-[10.5px] tracking-[0.16em] uppercase text-svgb20-muted">control &middot; fixed 2px</span>
        <div class="flex flex-wrap items-center gap-3.5">
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px] text-svgb20-muted" type="button" data-chars="3" style="--svgb-20-chars: 3">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:2px] [transition-duration:560ms]" data-chars="3" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Buy</span>
          </button>
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px] text-svgb20-muted" type="button" data-chars="35" style="--svgb-20-chars: 35">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:2px] [transition-duration:560ms]" data-chars="35" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Duplicate workspace and invite team</span>
          </button>
        </div>
      </div>
    </div>
  </div>
</section>
@import "tailwindcss";
/* Both properties must be registered, and neither can be a utility. An
   unregistered custom property is an untyped token, so calc() with a unit can
   fail to resolve — and an unregistered number cannot be interpolated, which
   is what the hover transition depends on. */

@property --svgb-20-chars {
  syntax: '<integer>';
  inherits: true;
  initial-value: 12;
}
/* the duty cycle of the measure: 0.65 = ticks with gaps, 1 = a closed border */

@property --svgb-20-duty {
  syntax: '<number>';
  inherits: true;
  initial-value: 0.65;
}

@theme {
  --color-svgb20-bg: #101010;
  --color-svgb20-bg: oklch(0.18 0 0);
  --color-svgb20-fg: #f4f4f0;
  --color-svgb20-fg: oklch(0.96 0.005 100);
  --color-svgb20-muted: #8d8d86;
  --color-svgb20-muted: oklch(0.67 0.008 100);
  --color-svgb20-rest: rgba(244, 244, 240, 0.16);
  --color-svgb20-rest: oklch(0.96 0.005 100 / 0.16);
  --color-svgb20-stroke: #d8ff2b;
  --color-svgb20-stroke: oklch(0.93 0.22 116);
  --font-svgb20-sans: 'Archivo', system-ui, sans-serif;
  --font-svgb20-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Progressive enhancement: attr() with a type reads the count straight off the
   rect and drops the inline custom property entirely. A feature query is not a
   variant, so this cannot move into the markup. */

@supports (stroke-width: attr(data-chars type(<number>))) {
  .svgb-20__ink {
    stroke-width: clamp(1.5px, calc(1.1px + attr(data-chars type(<number>), 12) * 0.055px), 3px);
  }
}

.svgb-20:has(#svgb-20-theme:checked) {
  --color-svgb20-bg: #f4f4f0;
  --color-svgb20-bg: oklch(0.96 0.005 100);
  --color-svgb20-fg: #111111;
  --color-svgb20-fg: oklch(0.185 0 0);
  --color-svgb20-muted: #5e5e58;
  --color-svgb20-muted: oklch(0.50 0.008 100);
  --color-svgb20-rest: rgba(17, 17, 17, 0.18);
  --color-svgb20-rest: oklch(0.185 0 0 / 0.18);
  --color-svgb20-stroke: #4a5c00;
  --color-svgb20-stroke: oklch(0.44 0.14 116);
}
/* Optional convenience: the count can equally be printed into the markup by your template. */
(function () {
  var root = document.querySelector('.svgb-20');
  if (!root) return;
  root.querySelectorAll('.svgb-20__btn').forEach(function (btn) {
    var n = btn.querySelector('.svgb-20__label').textContent.trim().length;
    btn.dataset.chars = String(n);
    btn.style.setProperty('--svgb-20-chars', String(n));
    /* attr() resolves against the element the declaration sits on, so the rect
       needs its own copy — the button's attribute is not in scope for it. */
    var ink = btn.querySelector('.svgb-20__ink');
    if (ink) ink.dataset.chars = String(n);
  });
})();

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";
/* Both properties must be registered, and neither can be a utility. An
   unregistered custom property is an untyped token, so calc() with a unit can
   fail to resolve — and an unregistered number cannot be interpolated, which
   is what the hover transition depends on. */

@property --svgb-20-chars {
  syntax: '<integer>';
  inherits: true;
  initial-value: 12;
}
/* the duty cycle of the measure: 0.65 = ticks with gaps, 1 = a closed border */

@property --svgb-20-duty {
  syntax: '<number>';
  inherits: true;
  initial-value: 0.65;
}

@theme {
  --color-svgb20-bg: #101010;
  --color-svgb20-bg: oklch(0.18 0 0);
  --color-svgb20-fg: #f4f4f0;
  --color-svgb20-fg: oklch(0.96 0.005 100);
  --color-svgb20-muted: #8d8d86;
  --color-svgb20-muted: oklch(0.67 0.008 100);
  --color-svgb20-rest: rgba(244, 244, 240, 0.16);
  --color-svgb20-rest: oklch(0.96 0.005 100 / 0.16);
  --color-svgb20-stroke: #d8ff2b;
  --color-svgb20-stroke: oklch(0.93 0.22 116);
  --font-svgb20-sans: 'Archivo', system-ui, sans-serif;
  --font-svgb20-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Progressive enhancement: attr() with a type reads the count straight off the
   rect and drops the inline custom property entirely. A feature query is not a
   variant, so this cannot move into the markup. */

@supports (stroke-width: attr(data-chars type(<number>))) {
  .svgb-20__ink {
    stroke-width: clamp(1.5px, calc(1.1px + attr(data-chars type(<number>), 12) * 0.055px), 3px);
  }
}

.svgb-20:has(#svgb-20-theme:checked) {
  --color-svgb20-bg: #f4f4f0;
  --color-svgb20-bg: oklch(0.96 0.005 100);
  --color-svgb20-fg: #111111;
  --color-svgb20-fg: oklch(0.185 0 0);
  --color-svgb20-muted: #5e5e58;
  --color-svgb20-muted: oklch(0.50 0.008 100);
  --color-svgb20-rest: rgba(17, 17, 17, 0.18);
  --color-svgb20-rest: oklch(0.185 0 0 / 0.18);
  --color-svgb20-stroke: #4a5c00;
  --color-svgb20-stroke: oklch(0.44 0.14 116);
}
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=Archivo:wdth,wght@75..125,400;75..125,700&family=JetBrains+Mono:wght@400;500&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.
  • The character count arrives as an inline custom property — style=&quot;--svgb-20-chars: 22&quot; — and every derived value is an arbitrary utility reading it: stroke width, font size, dash pattern and the transition duration.
  • Hover transitions the dash pattern, not the offset: hover:[--svgb-20-duty:1] on the button closes every gap until the measure becomes a solid border. That is a state change no dash-offset draw can express.
  • Both @property blocks stay in CSS, and they are the load-bearing part: an unregistered --svgb-20-duty cannot be interpolated, so the gaps would snap shut instead of closing.
  • SVG geometry is set through arbitrary properties — [x:1.5px] [width:calc(100%-3px)] [rx:4px] — which is what lets one rect fit six button widths with no viewBox at all.
  • The dash pattern divides by the count on a pathLength=&quot;1&quot; rect, so calc(var(--svgb-20-duty)/var(--svgb-20-chars)) is exactly one tick per character at any width.
  • The attr(data-chars type(<number>)) enhancement also stays in CSS: a feature query has no variant form, and the declaration has to resolve on the rect that carries the attribute rather than on its button.
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 Variable Width Stroke Button with Text Length
Source: https://codefronts.com/components/tailwind-svg-stroke-buttons/tailwind-variable-width-stroke-button-with-text-length/

Six labels from three characters to thirty-five, each deriving its stroke weight, dash rhythm and draw duration from one inline number. Hover transitions the dash pattern rather than the offset &mdash; hover:[--svgb-20-duty:1] closes every gap until the measure becomes a solid border, a state change no dash-offset draw can express. Both @property blocks stay in CSS, and they are the load-bearing part.
## HTML
```html
<section class="svgb-20 relative block w-full min-h-svh bg-svgb20-bg text-svgb20-fg font-svgb20-sans">
  <input class="peer absolute opacity-0 pointer-events-none" type="checkbox" id="svgb-20-theme">
  <label class="absolute z-[5] top-4 right-4 grid place-items-center size-11 rounded-sm border border-current/22 bg-current/7 cursor-pointer transition-[background-color,transform] duration-150 hover:bg-current/14 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-20-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="min-h-svh grid place-items-center p-[clamp(22px,5vw,68px)]">
    <div class="w-full max-w-225 flex flex-col gap-[clamp(20px,3vw,32px)]">
      <div class="flex flex-wrap items-end justify-between gap-3.5 pb-4 border-b border-svgb20-rest">
        <h1 class="m-0 text-[clamp(38px,7vw,82px)] font-bold [font-stretch:118%] leading-[0.88] tracking-[-0.045em] uppercase">Weight<br>Ladder</h1>
        <p class="m-0 font-svgb20-mono text-[11px] tracking-[0.02em] text-svgb20-muted">stroke-width: clamp(1.5px, 1.1px + chars &times; 0.055px, 3px)</p>
      </div>

      <ol class="list-none m-0 p-0 flex flex-col gap-[clamp(12px,2vw,18px)]">
        <li class="grid grid-cols-[34px_1fr_54px] gap-[clamp(12px,2.5vw,22px)] items-center">
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted">03</span>
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px]" type="button" data-chars="3" style="--svgb-20-chars: 3">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:clamp(1.5px,calc(1.1px+var(--svgb-20-chars)*0.055px),3px)]" data-chars="3" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Buy</span>
          </button>
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted text-end">1.5px</span>
        </li>
        <li class="grid grid-cols-[34px_1fr_54px] gap-[clamp(12px,2.5vw,22px)] items-center">
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted">08</span>
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px]" type="button" data-chars="8" style="--svgb-20-chars: 8">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:clamp(1.5px,calc(1.1px+var(--svgb-20-chars)*0.055px),3px)]" data-chars="8" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Add note</span>
          </button>
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted text-end">1.5px</span>
        </li>
        <li class="grid grid-cols-[34px_1fr_54px] gap-[clamp(12px,2.5vw,22px)] items-center">
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted">13</span>
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px]" type="button" data-chars="13" style="--svgb-20-chars: 13">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:clamp(1.5px,calc(1.1px+var(--svgb-20-chars)*0.055px),3px)]" data-chars="13" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Export as CSV</span>
          </button>
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted text-end">1.8px</span>
        </li>
        <li class="grid grid-cols-[34px_1fr_54px] gap-[clamp(12px,2.5vw,22px)] items-center">
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted">22</span>
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px]" type="button" data-chars="22" style="--svgb-20-chars: 22">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:clamp(1.5px,calc(1.1px+var(--svgb-20-chars)*0.055px),3px)]" data-chars="22" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Schedule for next week</span>
          </button>
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted text-end">2.3px</span>
        </li>
        <li class="grid grid-cols-[34px_1fr_54px] gap-[clamp(12px,2.5vw,22px)] items-center">
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted">35</span>
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px]" type="button" data-chars="35" style="--svgb-20-chars: 35">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:clamp(1.5px,calc(1.1px+var(--svgb-20-chars)*0.055px),3px)]" data-chars="35" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Duplicate workspace and invite team</span>
          </button>
          <span class="font-svgb20-mono text-[11px] tabular-nums text-svgb20-muted text-end">3.0px</span>
        </li>
      </ol>

      <div class="flex flex-col gap-3 pt-[18px] border-t border-svgb20-rest">
        <span class="font-svgb20-mono text-[10.5px] tracking-[0.16em] uppercase text-svgb20-muted">control &middot; fixed 2px</span>
        <div class="flex flex-wrap items-center gap-3.5">
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px] text-svgb20-muted" type="button" data-chars="3" style="--svgb-20-chars: 3">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:2px] [transition-duration:560ms]" data-chars="3" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Buy</span>
          </button>
          <button class="group relative inline-grid place-items-center justify-self-start min-w-24 min-h-[62px] px-[clamp(20px,2.6vw,32px)] border-0 rounded bg-transparent font-normal tracking-[-0.01em] text-[clamp(15px,calc(14px+var(--svgb-20-chars)*0.04px),18px)] cursor-pointer [transition:--svgb-20-duty_calc(340ms+var(--svgb-20-chars)*14ms)_cubic-bezier(0.22,1,0.36,1)] hover:[--svgb-20-duty:1] focus-visible:[--svgb-20-duty:1] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-svgb20-stroke focus-visible:outline-offset-[3px] text-svgb20-muted" type="button" data-chars="35" style="--svgb-20-chars: 35">
            <svg class="absolute inset-0 w-full h-full overflow-visible" aria-hidden="true"><rect class="svgb-20__ink fill-none [x:1.5px] [y:1.5px] [width:calc(100%-3px)] [height:calc(100%-3px)] [rx:4px] stroke-svgb20-rest [stroke-linecap:butt] [stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))] transition-[stroke] duration-[240ms] group-hover:stroke-svgb20-stroke group-focus-visible:stroke-svgb20-stroke motion-reduce:[stroke-dasharray:none] [stroke-width:2px] [transition-duration:560ms]" data-chars="35" pathLength="1" vector-effect="non-scaling-stroke"/></svg>
            <span class="relative z-[1] whitespace-nowrap transition-colors duration-[260ms] motion-reduce:transition-none group-hover:text-svgb20-stroke group-focus-visible:text-svgb20-stroke">Duplicate workspace and invite team</span>
          </button>
        </div>
      </div>
    </div>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";
/* Both properties must be registered, and neither can be a utility. An
   unregistered custom property is an untyped token, so calc() with a unit can
   fail to resolve — and an unregistered number cannot be interpolated, which
   is what the hover transition depends on. */

@property --svgb-20-chars {
  syntax: '<integer>';
  inherits: true;
  initial-value: 12;
}
/* the duty cycle of the measure: 0.65 = ticks with gaps, 1 = a closed border */

@property --svgb-20-duty {
  syntax: '<number>';
  inherits: true;
  initial-value: 0.65;
}

@theme {
  --color-svgb20-bg: #101010;
  --color-svgb20-bg: oklch(0.18 0 0);
  --color-svgb20-fg: #f4f4f0;
  --color-svgb20-fg: oklch(0.96 0.005 100);
  --color-svgb20-muted: #8d8d86;
  --color-svgb20-muted: oklch(0.67 0.008 100);
  --color-svgb20-rest: rgba(244, 244, 240, 0.16);
  --color-svgb20-rest: oklch(0.96 0.005 100 / 0.16);
  --color-svgb20-stroke: #d8ff2b;
  --color-svgb20-stroke: oklch(0.93 0.22 116);
  --font-svgb20-sans: 'Archivo', system-ui, sans-serif;
  --font-svgb20-mono: 'JetBrains Mono', ui-monospace, monospace;
}
/* Progressive enhancement: attr() with a type reads the count straight off the
   rect and drops the inline custom property entirely. A feature query is not a
   variant, so this cannot move into the markup. */

@supports (stroke-width: attr(data-chars type(<number>))) {
  .svgb-20__ink {
    stroke-width: clamp(1.5px, calc(1.1px + attr(data-chars type(<number>), 12) * 0.055px), 3px);
  }
}

.svgb-20:has(#svgb-20-theme:checked) {
  --color-svgb20-bg: #f4f4f0;
  --color-svgb20-bg: oklch(0.96 0.005 100);
  --color-svgb20-fg: #111111;
  --color-svgb20-fg: oklch(0.185 0 0);
  --color-svgb20-muted: #5e5e58;
  --color-svgb20-muted: oklch(0.50 0.008 100);
  --color-svgb20-rest: rgba(17, 17, 17, 0.18);
  --color-svgb20-rest: oklch(0.185 0 0 / 0.18);
  --color-svgb20-stroke: #4a5c00;
  --color-svgb20-stroke: oklch(0.44 0.14 116);
}
```

## JavaScript
```js
/* Optional convenience: the count can equally be printed into the markup by your template. */
(function () {
  var root = document.querySelector('.svgb-20');
  if (!root) return;
  root.querySelectorAll('.svgb-20__btn').forEach(function (btn) {
    var n = btn.querySelector('.svgb-20__label').textContent.trim().length;
    btn.dataset.chars = String(n);
    btn.style.setProperty('--svgb-20-chars', String(n));
    /* attr() resolves against the element the declaration sits on, so the rect
       needs its own copy — the button's attribute is not in scope for it. */
    var ink = btn.querySelector('.svgb-20__ink');
    if (ink) ink.dataset.chars = String(n);
  });
})();
```

How this works

One number, many consequences. The label's character count arrives inline and everything else is arithmetic on it:

<button style="--svgb-20-chars: 22"
  class="[--svgb-20-duty:0.55]
         hover:[--svgb-20-duty:1]">
class="[stroke-dasharray:calc(var(--svgb-20-duty)/var(--svgb-20-chars))_calc((1-var(--svgb-20-duty))/var(--svgb-20-chars))]"

Because the rect carries pathLength="1", dividing by the count gives exactly one tick per character — a 3-character label gets three coarse ticks, a 35-character label gets thirty-five fine ones, and the optical weight stays even.

Transitioning the pattern is a different idea. Every other demo here moves the offset. This one animates the duty cycle: at rest the ticks have gaps, on hover the duty goes to 1 and the measure becomes a solid border. There is no way to express “the gaps close” as a dash-offset animation.

Which is why @property is load-bearing. An unregistered custom property is a string to the interpolation engine, so --svgb-20-duty would jump from 0.55 to 1 in one frame. Registered as a <number>, it tweens — and the gaps visibly close. This cannot be a utility, and dropping it silently breaks the only effect the demo has.

@property --svgb-20-duty {
  syntax: '<number>'; inherits: true; initial-value: 0.55;
}

No viewBox at all. The rect is positioned with arbitrary geometry properties and a percentage width, so it fits whatever the label makes the button. That is what keeps six very different widths on one class string, and it is why nothing here needs measuring.

Make it yours

  • Change the resting duty cycle to make the measure denser or airier; 0.55 reads as a ruler, 0.85 as a nearly solid line.
  • Retime the close from the count-derived duration if long labels feel slow — the transition is deliberately proportional.
  • Drop the control pair at the bottom once the point is made; they exist to show the problem, not to be UI.
  • Feed the count from your template rather than the script — the script is a convenience, not a dependency.
  • Scale the stroke-width expression if your type is much heavier than this specimen; the optical logic holds, the constants do not.

Gotchas — read before shipping

  • Without @property the duty cycle cannot interpolate and the gaps snap shut. This is the one demo where a registration is the effect rather than a nicety.
  • calc() inside arbitrary values must have no spaces, which makes these class strings long and unforgiving — copy them whole.
  • pathLength=&quot;1&quot; is required for the per-character division to mean anything.
  • A percentage width on an SVG rect resolves against the SVG's own width, so the demo works only because there is no viewBox scaling in between.
  • attr() with a type is very new; the enhancement is guarded and the inline variable remains the reliable path.
  • Deriving stroke weight from character count is an optical correction, not a rule — check it against your actual type, because a heavy display face needs different constants.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

@property sets the real floor (Chrome 85, Safari 16.4, Firefox 128) and is what makes the duty cycle animate. The attr(data-chars type(<number>)) enhancement is Chrome-only for now and guarded by a feature query, so the inline custom property is what actually runs everywhere.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Arbitraryneeds bracket syntax

Plain CSSno Tailwind surface

Search CodeFronts

Loading…