CSS Border Draw Button

A button whose border traces itself on hover using SVG stroke-dashoffset animation.

Published July 10, 2026 Intermediate 9 min read

A CSS border draw button traces its own outline when the pointer arrives, using stroke-dasharray and stroke-dashoffset on an SVG shape laid over the button. The two properties turn a stroke into a dash pattern and then slide that pattern along the path, and sliding a dash exactly as long as the path is indistinguishable from drawing it. Nothing is animated except one number.

The three buttons below all use the same technique with different colors. Two more builds follow: a version that uses the pathLength attribute so the dash numbers stop depending on the button's dimensions, and a continuous chase outline for a control that is busy. The same pair of properties draws the arc on the animated donut chart.

Hover to trace the outline

HTML

<div class="draw-btns">
  <button class="draw-btn">
    <svg class="btn-border" viewBox="0 0 160 44" preserveAspectRatio="none">
      <rect class="border-rect" x="1" y="1" width="158" height="42" rx="8" ry="8"/>
    </svg>
    <span>Hover me</span>
  </button>
  <button class="draw-btn draw-btn-blue">
    <svg class="btn-border" viewBox="0 0 160 44" preserveAspectRatio="none">
      <rect class="border-rect" x="1" y="1" width="158" height="42" rx="8" ry="8"/>
    </svg>
    <span>Draw border</span>
  </button>
  <button class="draw-btn draw-btn-pink">
    <svg class="btn-border" viewBox="0 0 160 44" preserveAspectRatio="none">
      <rect class="border-rect" x="1" y="1" width="158" height="42" rx="8" ry="8"/>
    </svg>
    <span>CSS only</span>
  </button>
</div>

CSS

.draw-btns {
  display: flex;
  gap: 1.5rem;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  padding: .5rem;
}

.draw-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: .75rem 2rem;
  background: transparent;
  border: none;
  color: #f0f0f0;
  font-family: system-ui, sans-serif;
  font-size: .95rem;
  font-weight: 600;
  letter-spacing: .02em;
  cursor: pointer;
}

.btn-border {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* 387 is the traced length of a 158x42 rect with 8px corners, so one
   dash covers the whole outline and the offset hides all of it */
.border-rect {
  fill: none;
  stroke: #b8ff57;
  stroke-width: 2;
  stroke-dasharray: 387;
  stroke-dashoffset: 387;
  transition: stroke-dashoffset .6s cubic-bezier(0.4, 0, 0.2, 1);
}

.draw-btn:hover .border-rect {
  stroke-dashoffset: 0;
}

.draw-btn-blue { color: #38bdf8; }
.draw-btn-blue .border-rect { stroke: #38bdf8; }

.draw-btn-pink { color: #f472b6; }
.draw-btn-pink .border-rect { stroke: #f472b6; }

Other ways to build it

Normalized with pathLength, and reachable by keyboard

The pathLength attribute tells the browser to pretend the shape is however many units long you say it is. Set it to 100 and the dash numbers become percentages: stroke-dasharray: 100 covers the outline whatever its real size, which is why the third button below is far wider than the other two and still uses the same rule. Note that none of these strokes carries vector-effect: non-scaling-stroke. It would even out the line weight the stretched viewBox distorts, and it would also move the dash lengths into device pixels and undo the normalisation. The buttons here also draw on :focus-visible, keep a faint outline at rest so they look like buttons before anyone touches them, and carry a real outline on focus, because a border that takes 600ms to arrive is decoration rather than a focus indicator.

HTML

<button class="bd-btn">
  <svg class="bd-border" viewBox="0 0 160 44"
       preserveAspectRatio="none" aria-hidden="true">
    <rect class="bd-track" x="1" y="1" width="158" height="42" rx="8" ry="8"/>
    <rect class="bd-ink" pathLength="100"
          x="1" y="1" width="158" height="42" rx="8" ry="8"/>
  </svg>
  <span>Hover or tab</span>
</button>

CSS

/* the faint outline that is always there */
.bd-track {
  fill: none;
  stroke: #2a2a2d;
  stroke-width: 2;
}

/* pathLength="100" means these numbers are percentages of the outline,
   so the same rule fits any button size and any corner radius.
   Do NOT add vector-effect: non-scaling-stroke here: it moves the dash
   lengths into device pixels and the normalisation stops applying. */
.bd-ink {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-dasharray: 100;
  stroke-dashoffset: 100;
  transition: stroke-dashoffset .6s cubic-bezier(0.4, 0, 0.2, 1);
}

.bd-btn:hover .bd-ink,
.bd-btn:focus-visible .bd-ink {
  stroke-dashoffset: 0;
}

/* the drawing takes 600ms, which is far too slow to serve as the focus
   indicator on its own, so the outline does that job immediately */
.bd-btn:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 4px;
}

@media (prefers-reduced-motion: reduce) {
  .bd-ink { transition: none; }
}

A chase outline for a busy control

Shortening the dash so it no longer covers the whole shape turns the same two properties into a loop. stroke-dasharray: 22 78 puts 22 units of ink and 78 units of gap on a path normalized to 100, and animating the offset to -100 runs that segment once around the outline per cycle. A negative offset moves the pattern forwards along the path, which is why the dash travels clockwise rather than backwards. Under reduced motion the traveling dash is replaced by a slow fade on the full outline, because removing it entirely would remove the only signal that the control is doing something.

Processing
Uploading

HTML

<div class="draw-btns">
  <div class="bd-loader">
    <svg class="bd-border" viewBox="0 0 160 44" preserveAspectRatio="none" aria-hidden="true">
      <rect class="bd-track" x="1" y="1" width="158" height="42" rx="8" ry="8"/>
      <rect class="bd-chase" pathLength="100" x="1" y="1" width="158" height="42" rx="8" ry="8"/>
    </svg>
    <span>Processing</span>
  </div>
  <div class="bd-loader bd-loader-violet">
    <svg class="bd-border" viewBox="0 0 160 44" preserveAspectRatio="none" aria-hidden="true">
      <rect class="bd-track" x="1" y="1" width="158" height="42" rx="8" ry="8"/>
      <rect class="bd-chase" pathLength="100" x="1" y="1" width="158" height="42" rx="8" ry="8"/>
    </svg>
    <span>Uploading</span>
  </div>
</div>

CSS

/* 22 units of ink, 78 of gap, on a path normalized to 100 */
.bd-chase {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-dasharray: 22 78;
  animation: border-chase 2.4s linear infinite;
}

/* negative offset walks the pattern forwards along the path */
@keyframes border-chase {
  to { stroke-dashoffset: -100; }
}

/* a pending state has to keep signalling, so the motion is replaced
   rather than removed */
@keyframes border-pulse {
  0%, 100% { opacity: .25; }
  50%      { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .bd-chase {
    stroke-dasharray: none;
    animation: border-pulse 2s ease-in-out infinite;
  }
}

How it works

stroke-dasharray sets the length of the dash pattern on an SVG stroke. Setting it equal to the traced length of the shape makes the whole outline a single dash. stroke-dashoffset shifts that dash along the path, so an offset equal to the dash length pushes the dash off the shape entirely and leaves the gap covering it. Transitioning the offset to 0 on hover slides the dash back into place, which reads as the border drawing itself.

A single value in stroke-dasharray sets the dash and the gap to the same length. stroke-dasharray: 387 therefore means 387 units of ink followed by 387 units of nothing, repeating. Because 387 is roughly the traced length of this outline, the whole shape is covered by one dash and nothing repeats. stroke-dashoffset: 387 then shifts the pattern back by a full dash, which slides the ink off the start of the path and leaves the gap sitting over the entire shape. Moving that offset to 0 walks the ink back on from one end, which is why the effect looks like a pen rather than a fade.

Getting the length right is the fiddly part. The perimeter of a plain rectangle is 2 x (width + height), but rounded corners are shorter than the square corners they replace: each one drops two straight runs of length r and adds a quarter circle of length (pi / 2) x r. For the 158 by 42 rectangle here with an 8px radius that works out at about 386, hence the 387 in the stylesheet. Guess too low and a slice of the stroke is still visible when the button is at rest. Guess too high and the draw finishes before the pointer expects it to. The first variant below removes the arithmetic entirely.

An SVG overlay is doing work a CSS border cannot. A border has no notion of progress along its own path, so drawing one in CSS means animating border-width on four edges, or growing two pseudo-elements along the sides, and neither of those follows a rounded corner. stroke-dashoffset is a single number that walks the whole outline, curves included. The cost is one extra element per button and an SVG that has to stretch to the button's real size.

That stretching has a side effect worth knowing, and a trap behind it. preserveAspectRatio="none" lets the 160 by 44 viewBox squash to whatever the button turns out to be, and it scales the stroke along with everything else, so a button much wider than the viewBox gets thicker vertical edges than horizontal ones and a corner radius that reads as an ellipse. The obvious fix is vector-effect: non-scaling-stroke, which pins the stroke to a constant device width. Adding it to a dashed stroke breaks the dashing instead: the dash pattern is then measured in device pixels rather than user units, so a pathLength normalized stroke-dasharray: 100 stops covering the outline and the stroke ends up half drawn at rest. Under a stretched viewBox you can have a constant stroke width or size independent dash numbers, not both. Keeping the viewBox close to the button's real proportions avoids having to choose.

The transition lives on the base rule rather than inside :hover, so it governs the draw and the undraw. Reverse it and the border would appear instantly and only retract slowly. Where the drawing starts is decided by the shape: a <rect> begins its path just after the top left corner and runs clockwise, so the ink enters along the top edge. Swap the <rect> for a <path> and you control the start point, which is how these buttons get made to draw from the center outwards in both directions.

CSS properties used

stroke-dasharray
The dash and gap pattern along a stroke. One value sets both. Setting it to the traced length of the shape makes the entire outline a single dash with nothing repeating.
stroke-dashoffset
Slides the pattern along the path. Equal to the dash length it hides the stroke completely; at 0 the stroke is fully drawn. This is the only property being animated.
pathLength
An SVG attribute, not a CSS property. It tells the browser to treat the path as though it were this many units long, so the dash numbers stop depending on the real geometry.
vector-effect
non-scaling-stroke keeps the stroke width constant while the viewBox stretches. Leave it off any stroke you are dashing, because it moves the dash calculation into device pixels and cancels pathLength.
transition
Declared on the base rule so both the draw and the undraw are animated. Inside :hover it would only animate one direction.
fill
none on the rectangle, otherwise the shape paints over the button's own background and the label.

Browser support

FeatureChromeFirefoxSafariEdge
SVG433.212
transition455.112
vector-effect15155.179
:focus-visible86415.486
prefers-reduced-motion746310.179

Figures come from Can I Use. stroke-dasharray and stroke-dashoffset are part of SVG itself and have no separate entry, so they inherit the SVG row. The pathLength attribute is likewise SVG rather than CSS and has been in the specification since version 1.1, though a handful of older mobile engines ignored it and fell back to the real geometry, which makes a wrong looking dash the failure mode rather than a broken one. Safari applies :focus-visible only from 15.4, so pair it with a plain :focus rule if that matters to your audience.

Accessibility notes

A border that only appears on hover leaves keyboard users with a button that never changes. Add :focus-visible to the same selector list as :hover. That is one comma, and without it the control is invisible to anyone navigating by keyboard until they press it.

The drawn border should not be the only focus indicator. It takes 600ms to complete, and a focus state that arrives more than a moment after the key press reads as an unresponsive page. The variant below keeps a real outline on :focus-visible for the immediate feedback and lets the drawing run behind it as decoration. It also keeps a faint resting outline so the button is identifiable as a button before anyone interacts with it, which a transparent control with no border is not.

Give the <svg> aria-hidden="true". It carries no information, and without the attribute some screen readers announce a graphic between the button role and its label. The label itself belongs in a real text node inside the button, not in an SVG <text> element, so that it is selectable and scales with the reader's font size.

What you can build with it

  • Secondary calls to action. A quieter alternative to a filled button that still rewards the pointer, which suits a page that already has one strong primary action.
  • Card and tile hover states. The same rectangle scaled up to a card, drawing its outline as the pointer enters, using pathLength so the numbers survive a responsive layout.
  • Navigation links. An underline drawn with a two point <path> instead of a rectangle, which gives a link the same treatment for a fraction of the markup.
  • Busy and pending states. A short dash chasing around the outline, which tells the reader something is happening without adding a spinner beside the label.
  • Form field focus. An input whose outline traces in on focus, though this needs the immediate outline alongside it for the reasons above.

Mistakes worth avoiding

  • Getting the dash length wrong. Too short and part of the stroke never disappears, so the button has a stray line across one corner at rest. Too long and the draw completes early and then sits still. pathLength avoids the calculation altogether.
  • Putting the transition inside :hover. The border then snaps on instantly and only animates away, which is backwards: the arrival is the part the reader is watching.
  • Forgetting fill: none. The rectangle fills with black by default, covering the button's label completely, and because it is absolutely positioned over the top the button looks like a solid block.
  • Leaving the effect on :hover only. Roughly half of all traffic is touch, where there is no hover, and keyboard users get nothing at all. Add :focus-visible, and accept that on touch the button will simply have a resting border.
  • Adding vector-effect: non-scaling-stroke to a stroke you are also dashing. It fixes the uneven line weight a stretched viewBox produces and quietly breaks the dash lengths at the same time, because they switch from user units to device pixels. The button then sits with two thirds of its border drawn and no obvious cause.

Frequently asked questions

How do you animate a border in CSS?
Not with the border property, which cannot be drawn progressively. Overlay an SVG shape, set stroke-dasharray to the traced length of that shape, start with stroke-dashoffset at the same value, and transition it to 0. The stroke slides into place along the path, curves included.
How do I calculate stroke-dasharray for a rounded rectangle?
Start from 2 x (width + height), subtract 8 x radius for the straight parts the corners replace, then add 2 x pi x radius for the four quarter circles. Or skip it: put pathLength="100" on the shape and use 100 as both the dasharray and the starting offset.
Why is my stroke still visible when it should be hidden?
The dash length is shorter than the path, so the pattern repeats and a second dash appears further along. Measure the path with getTotalLength() in the console once, or normalize the geometry with pathLength.
Can the border draw from the middle out?
Yes, with a <path> rather than a <rect>. The dash always starts at the beginning of the path, so drawing outwards from a point means starting the path there and using two mirrored shapes, one running each way.
Does this work on touchscreens?
The drawing does not, because there is no hover on touch. The button still needs to look like a button, so keep a resting outline rather than relying on the hover state to reveal one.