20 hand-coded Tailwind SVG stroke buttons whose outlines draw themselves from stroke-dasharray and stroke-dashoffset utilities instead of a static border. These cover draw-on-hover and pathLength normalisation, then gradient strokes, dashed marching-ant crops, focus-visible keyboard outlines, submit loading and disabled states, staggered groups, and scroll-timeline reveals. Scoped under .svgb-NN for no-collision pasting, prefers-reduced-motion guarded, framework-agnostic.
You just browsed 20 Tailwind svg stroke button designs. Nucleo is a library of 44,000+ SVG icons in five families (Core, UI, Sharp, Pixel and Micro Bold), with web, Mac and Windows apps to manage them. The All Icons Bundle is $149 with lifetime updates.
The same two-pass pen sketch, written entirely in the markup. Tokens become @theme entries so the ink colour is a real stroke-svgb01-mark utility; the dash constant and the offset ride the paths as arbitrary properties; and group on the button replaces the descendant hover selector, so hovering and tabbing both drive group-hover:[stroke-dashoffset:0]. The only rule left in a stylesheet is the :has() theme flip, which utilities cannot express.
Three pads, three jobs for one number — ported so the difference between them is readable in the class lists. Both loops are declared once as --animate-* theme entries, so the markup only says animate-svgb02-orbit; hovering flips [animation-play-state:paused] to running rather than restarting the animation; and the twenty waveform bars share a single class list because h-(--h) reads each bar's own inline variable.
The measured draw, ported with its three layers still reading one clock. duration-[760ms] ease-svgb03 appears on the tape, the ink and the head alike, with the curve declared once in @theme — and reversing a cell needs no modifier class at all, just [stroke-dashoffset:-592] on the path. offset-path stays in CSS, because it takes literal path data and needs an @supports guard.
Two halves, one constant — and in Tailwind the constant is literally shared, because both paths carry the same class list character for character. The seam flash is built entirely from pseudo-element variants, from after:content-[''] through hover:after:delay-[620ms], where the delay equals the draw duration so the tear lands exactly as the two heads meet.
A radius inspector where picking a value cannot desynchronise the maths, because the radius and its measured perimeter are published by the same checked radio and read in the same breath: [rx:var(--svgb-05-rx,8px)] next to [stroke-dasharray:var(--svgb-05-peri,915)]. The four ghost contours each read their own --svgb-05-g-* opacity variable, so one invalidation point drives all of them.
Marching ants where they belong — a crop selection over a photograph — with the march declared once as --animate-svgb06-march so the marquee and the Apply button cannot drift apart. The only difference between a selection that marches forever and a button that marches on hover is two utilities: [animation-play-state:paused] group-hover:[animation-play-state:running].
Newsletter
Enjoying these demos? Get new ones by email.
A short email when a new collection ships. Unsubscribe any time.
A gradient border with no background-clip hack anywhere: the stroke is a paint-server reference, written as the arbitrary property [stroke:url(#svgb-07-grad)] because an id lookup is not a colour value. The pill and the badge both draw with [stroke-dasharray:1] [stroke-dashoffset:1] — two very different perimeters, one constant, because both paths carry pathLength="1".
Three keycaps, three transition declarations, side by side in the markup where you can actually compare them. The symmetric key reverses from wherever the pointer left it; the broken key uses group-hover:animate-svgb08-draw and snaps, because an animation has no memory of an interrupted position; the third declares a full [transition:...] twice to get 300ms in and 760ms out.
The accessibility argument of the collection, expressed as a naming convention: every hover utility here has a group-focus-visible: twin, so typing one and not the other leaves a visible hole in the class list. Focus also thickens the stroke and shortens the draw — a keyboard user is waiting on it, not exploring — and the forced-colors block stays in CSS because system keywords are not theme tokens.
Six languages, right-to-left included, each button sized by its own text — and a border that does not draw but wipes, using [clip-path:inset(0_100%_0_0)] instead of a dash constant, so nothing has to know the perimeter at any label width. The Arabic row is the lesson: clip-path takes physical edges only, so it needs rtl:[clip-path:inset(0_0_0_100%)].
Five silhouettes — hexagon, diamond, shield, octagon, ring — animated by one class string, character for character, apart from colour. Because every path carries pathLength="1", [stroke-dasharray:1] [stroke-dashoffset:1] is a complete outline on all of them, which means the string is genuinely reusable: extract it into an @utility and it animates any shape you point it at.
A crisp leader and a blurred follower 90ms behind it, which the eye reads as speed rather than as two lines. The offset is two utilities — duration-[620ms] on the leader, duration-[800ms] delay-[90ms] on the trail — and the comet pair animates the offset negative, so a 4.5% fraction of pathLength="1" travels the path instead of a line being drawn.
Four beats in, four beats out — and both ladders are visible on the same elements, because every step carries a delay-[...] for leaving and a group-hover:delay-[...] for arriving. All eight delays are multiples of one variable via delay-[calc(var(--svgb-13-step)*2.6)], so changing the beat retimes the whole cascade, and the second card becomes a two-beat version by setting that variable inline.
A ring hundreds of units round and a glyph a few dozen, drawing in perfect step because both carry pathLength="1" and share one duration-[640ms]. Multi-stroke glyphs are split into separate paths — a dash pattern restarts at every M — and the second gets delay-[60ms], which is the difference between drawn and switched on.
Newsletter
Enjoying these demos? Get new ones by email.
A short email when a new collection ships. Unsubscribe any time.
Three real states carried by one outline, with the script setting a single attribute and every visual consequence following from data-* variants: data-[state=busy]:bg-svgb15-stroke/7 on the button, group-data-[state=done]:opacity-100 on its children. No class juggling in JavaScript — and group-data-[state=idle]:group-hover:[stroke-dashoffset:0] stacks two variants to say draw on hover, but only while idle.
Three states that must be distinguishable both visually and semantically: a genuinely disabled stepper at the floor, an aria-disabled sign-off a keyboard user can still reach and learn from, and an error frame that appears instantly at full weight. The root carries group/state and each button its own named group, so one class list can react to both: group-data-[state=error]/state: for the screen, group-hover/btn: for the control.
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.
A wave across six platform tabs, driven entirely from the markup: group-hover/rail:delay-[calc(var(--i,0)*var(--svgb-18-step))] reads each item's own inline --i, so adding a seventh platform needs no CSS at all. Two named groups do two jobs — group/rail owns the wave, group/tab owns the individual lift — and the pointer-origin version stays in CSS, because there is no variant for two siblings after the hovered one.
The best argument for utilities in the whole collection: Tailwind's motion-reduce: variant is@media (prefers-reduced-motion: reduce), so the fallback lives in the markup next to the thing it replaces — you cannot ship the animation and forget the fallback in another file. And the fallback restates the end state rather than deleting the transition, because a bare transition-none leaves a button that never responds at all.
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.
FAQ
Frequently asked questions
How do you animate an SVG stroke to draw a button border with Tailwind utilities?
The border is a real <path> or <rect> inside an inline <svg>, and the draw is two arbitrary-value utilities on that stroke element. Set the dash array to the full perimeter and the offset to the same number so the line starts fully hidden: [stroke-dasharray:400] and [stroke-dashoffset:400]. On hover, drive the offset back to zero with a group variant, e.g. group-hover:[stroke-dashoffset:0], plus a transition-[stroke-dashoffset] and a duration. Because the dash gap equals the visible length, the stroke appears to draw from one point around the shape. Demo 01 ports the two-pass sketch and Demo 02 shows the same one number doing three different jobs, so you can read the dash-array, dash-offset, and gap relationship directly in the class list instead of guessing it from compiled CSS.
Why does my Tailwind stroke button flash a fully-drawn border for a split second on load?
That flash means the idle state is wrong: the stroke is painting its full length before your hover utility ever applies. The fix is to make the resting state the hidden one. Put [stroke-dashoffset:400] (equal to the dash array) on the base element, not inside a variant, so the border is invisible until interaction moves the offset to zero. A second cause is a missing [stroke-dasharray] value — with no dash array the whole path is one solid segment and the offset has nothing to slide against, so the browser renders the complete outline immediately. Verify the two numbers match the true perimeter; if the dash array is shorter than the path, the tail wraps and you see a stub of border at rest. Demos 01 and 03 keep both numbers visible in the markup precisely so this desync is easy to spot before it ships.
How do I keep the stroke width from getting thicker when the SVG button scales with its text?
Use the paint keyword vector-effect="non-scaling-stroke" on the stroke element. Normally an <svg> scales its geometry and its stroke together, so a button sized by a long label ends up with a heavier border than a short one. The non-scaling keyword tells the renderer to draw the stroke at its declared width in screen pixels regardless of the element's transform or viewBox scaling, so a one-pixel outline stays one pixel whether the button holds three characters or thirty. Demo 10 sizes six buttons — including a right-to-left label — entirely by their own text and the border weight never drifts. Note this is an SVG attribute, not a Tailwind utility; you write it on the markup. Pair it with pathLength normalization if you also animate the draw, so the timing stays constant across the different perimeters.
What does pathLength do and why do stroke buttons of different shapes draw at different speeds without it?
By default a dash array is measured in real user units, so a wide rectangle has a longer perimeter than a small ring and the same transition-duration makes the big one appear to draw faster per side. Setting pathLength="100" on the stroke element remaps its measured length to a flat 100 units, so [stroke-dasharray:100] and [stroke-dashoffset:100] mean the same thing on every shape. Now one class string drives a hexagon, a diamond, a shield and a ring at an identical rate, as Demo 11 shows across five silhouettes. Demo 14 uses it to sync a large ring and a small glyph so both finish drawing on the same beat. Without it you would hand-tune a different dash number per shape and re-tune every time the geometry changes — pathLength makes the timing character-count and size independent.
How do I make a Tailwind stroke button respond to keyboard focus, not just mouse hover?
Add the draw to the focus-visible: variant alongside group-hover:, so keyboard users get the same outline animation a mouse triggers: group-hover:[stroke-dashoffset:0] focus-visible:[stroke-dashoffset:0]. The focus-visible variant only fires for keyboard focus, not pointer clicks, so mouse users do not see a lingering ring after they click. Do not remove the native focus ring unless your drawn stroke is genuinely visible against every background — an animated border that only shows on a light panel leaves keyboard users with no indicator on a dark one. Demo 09 treats this as a naming convention: every hover utility has a matching focus-visible twin, which is the whole accessibility argument of the collection. If you strip the browser outline, the stroke draw is your focus indicator, so it must meet contrast on its own.
How do you build a loading or submit state on a stroke button without JavaScript rewriting the classes?
Drive one attribute from script and let variants do the rest. Set something like data-state="loading" on the button, then style with an arbitrary variant such as data-[state=loading]:[stroke-dashoffset:0] or an animated dash for a spinner effect. The script sets a single attribute; every visual consequence — the outline, the label fade, the disabled look — is expressed declaratively in the markup, which is exactly how Demo 15 carries three real states through one outline. For a genuine disabled state, use the real disabled attribute (Demo 16) so the control is non-focusable and announced correctly, and pair it with a disabled: variant for the muted styling. Avoid faking disabled with only a class — that leaves the button clickable and reachable by keyboard, which is a functional bug, not just a visual one.
How can I draw a stroke button on scroll using animation-timeline instead of an IntersectionObserver?
Demo 17 uses the scroll-driven animations spec: declare a keyframe that moves stroke-dashoffset from the full length to zero, then attach it with an arbitrary [animation-timeline:view()] and an [animation-range:...] so the draw is tied to the element's position in the viewport rather than to a hover or a timer. There is no observer and no scroll listener — the browser advances the animation as you scroll and rewinds it as you scroll back. The tradeoff is support: animation-timeline is not implemented everywhere, so treat the scroll draw as an enhancement over a visible resting state, never as the only way the border appears. If the property is unsupported the keyframe simply does not run, so make sure the un-animated button already looks complete on its own.
When should I not use an animated SVG stroke button, and how do I respect reduced motion?
Skip the draw animation for anything a user hits repeatedly under time pressure — a form submit they retry, a primary action in a dense toolbar — where a 700ms border sketch reads as lag rather than polish; there a plain static outline serves better. Always gate the motion behind Tailwind's motion-reduce: variant, which maps to the prefers-reduced-motion media query. Demo 19 makes this the strongest case for utilities: write motion-reduce:transition-none or motion-reduce:[stroke-dashoffset:0] right beside the animated utility, so the reduced-motion path is visible in the same class list instead of buried in a separate block. The rule is that the border must reach its final visible state instantly for those users — never leave it stuck at a hidden stroke-dashoffset, or reduced-motion visitors get a button with no outline at all.