CSS Border Animations: 16 Examples Without Layout Shift

Most CSS border animation is a color change on hover, one transition line. The fancier versions animate width or a painted pattern, which is where layout shift creeps in.

Below are 16 border animations you can try and copy, followed by a guide to methods, paint cost and accessibility.

By Bogdan Sandu ยท Updated October 28, 2026

Hover and focus borders

Borders that react to a pointer or the keyboard without moving a single neighbor. Each one animates a background layer, a box-shadow, a clip-path or a transform, never border-width, and every hover rule has a matching focus rule.

Corner brackets that close into a frame

  • @property length
  • background-size
  • aria-pressed

Hover or tab to each example.

Eight one-color background layers on a pseudo-element paint the four brackets, and a registered length sets how long each arm is. Hover stretches the arms a little, selecting the photo stretches them to 50% so they meet and form a full frame. Nothing about the box size changes, so the grid around it stays put.

View code
@property --ba-arm {
  syntax: "<length-percentage>";
  inherits: false;
  initial-value: 22px;
}
.ba-frame {
  --ba-ink: #1c1917;
  position: relative;
  display: block;
  width: min(300px, 100%);
  padding: 14px;
  border: 0;
  border-radius: 2px;
  background: #fff;
  color: #1c1917;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.ba-frame::before {
  --ba-arm: 22px;
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    linear-gradient(var(--ba-ink) 0 0) left top / var(--ba-arm) 2px,
    linear-gradient(var(--ba-ink) 0 0) left top / 2px var(--ba-arm),
    linear-gradient(var(--ba-ink) 0 0) right top / var(--ba-arm) 2px,
    linear-gradient(var(--ba-ink) 0 0) right top / 2px var(--ba-arm),
    linear-gradient(var(--ba-ink) 0 0) left bottom / var(--ba-arm) 2px,
    linear-gradient(var(--ba-ink) 0 0) left bottom / 2px var(--ba-arm),
    linear-gradient(var(--ba-ink) 0 0) right bottom / var(--ba-arm) 2px,
    linear-gradient(var(--ba-ink) 0 0) right bottom / 2px var(--ba-arm);
  background-repeat: no-repeat;
}
.ba-frame img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.ba-frame__meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding-top: 12px;
  font-size: 14px;
}
.ba-frame__title {
  font-family: "Fraunces", Georgia, serif;
  font-size: 17px;
}
.ba-frame__state {
  padding: 3px 10px;
  border-radius: 99px;
  background: #f5f5f4;
  color: #44403c;
  font-weight: 600;
  transition: background-color .2s, color .2s;
}
.ba-frame:hover::before,
.ba-frame:focus-visible::before {
  --ba-arm: 34px;
}
.ba-frame[aria-pressed="true"] {
  --ba-ink: #1d4ed8;
}
.ba-frame[aria-pressed="true"]::before {
  --ba-arm: 50%;
}
.ba-frame[aria-pressed="true"] .ba-frame__state {
  background: #1d4ed8;
  color: #fff;
}
.ba-frame:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 5px;
}
@media (prefers-reduced-motion: no-preference) {
  .ba-frame::before {
    transition: --ba-arm .4s cubic-bezier(.3, .7, .2, 1);
  }
}

Double ring on a selected plan

  • box-shadow spread
  • :has(:checked)
  • radio cards
Billing period

Hover or tab to each example.

The selected card gets two stacked box-shadows with no blur: a ring in the panel color, then a ring in the accent color, which reads as a double border. Spread grows outward and takes no space, so the cards never jump. The border color switches instantly and only the spread is animated.

View code
.ba-plans {
  display: grid;
  gap: 14px;
  width: min(340px, 100%);
  margin: 0;
  padding: 22px 20px;
  border: 0;
  border-radius: 18px;
  background: #fffaf3;
}
.ba-plans__legend {
  float: left;
  width: 100%;
  margin-bottom: 4px;
  padding: 0;
  color: #431407;
  font-family: "Fraunces", Georgia, serif;
  font-size: 19px;
}
.ba-plans__opt {
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 12px;
  align-items: center;
  padding: 12px 16px;
  border: 1px solid #e7d8c9;
  border-radius: 12px;
  background: #fff;
  color: #431407;
  cursor: pointer;
  box-shadow: 0 0 0 0 #fffaf3, 0 0 0 0 #c2410c;
  transition: border-color .2s;
}
.ba-plans__opt input {
  grid-row: span 2;
  width: 18px;
  height: 18px;
  margin: 0;
  accent-color: #c2410c;
}
.ba-plans__name {
  font-weight: 700;
}
.ba-plans__price {
  color: #7c2d12;
  font-size: 14px;
}
.ba-plans__opt:hover {
  border-color: #c2410c;
}
.ba-plans__opt:has(:checked) {
  border-color: #c2410c;
  box-shadow: 0 0 0 4px #fffaf3, 0 0 0 6px #c2410c;
}
.ba-plans__opt:has(:focus-visible) {
  outline: 3px solid #1e3a8a;
  outline-offset: 9px;
}
@media (prefers-reduced-motion: no-preference) {
  .ba-plans__opt {
    transition: border-color .2s, box-shadow .3s cubic-bezier(.3, .7, .2, 1);
  }
}

Notices with a thickening accent bar

  • scaleX pseudo-element
  • transform-origin
  • :focus-within
  • Backup finished214 files copied to the Oslo bucket at 03:40.View log
  • Certificate expires in 9 daysRenew before 5 November to avoid browser warnings.Renew now
  • Domain verifiedMail from fernhill.example will now pass DKIM.DNS records

Hover or tab to each example.

The colored left edge is a 4px pseudo-element, not a border. On hover or focus it scales to 8px wide with transform, so the text never reflows and the browser can composite the change. The tint behind the notice is a plain color transition.

View code
.ba-notes {
  display: grid;
  gap: 12px;
  width: min(380px, 100%);
  margin: 0;
  padding: 0;
  list-style: none;
}
.ba-note {
  --ba-tone: #1d4ed8;
  --ba-tint: #eff4ff;
  position: relative;
  display: grid;
  gap: 2px;
  padding: 13px 16px 13px 22px;
  border-radius: 10px;
  background: #fff;
  color: #1f2937;
  font-size: 14px;
  line-height: 1.45;
  overflow: hidden;
  transition: background-color .2s;
}
.ba-note--warn {
  --ba-tone: #b45309;
  --ba-tint: #fff7e8;
}
.ba-note--ok {
  --ba-tone: #15803d;
  --ba-tint: #effaf2;
}
.ba-note::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 4px;
  background: var(--ba-tone);
  transform-origin: left center;
}
.ba-note strong {
  font-size: 15px;
}
.ba-note a {
  justify-self: start;
  margin-top: 4px;
  color: var(--ba-tone);
  font-weight: 600;
  text-underline-offset: 3px;
}
.ba-note a:focus-visible {
  outline: 2px solid var(--ba-tone);
  outline-offset: 2px;
  border-radius: 3px;
}
.ba-note:hover,
.ba-note:focus-within {
  background: var(--ba-tint);
}
.ba-note:hover::before,
.ba-note:focus-within::before {
  transform: scaleX(2);
}
@media (prefers-reduced-motion: no-preference) {
  .ba-note::before {
    transition: transform .25s cubic-bezier(.3, .7, .2, 1);
  }
}

Underline that morphs into a box

  • clip-path: inset()
  • aria-current
  • pseudo-element

Hover or tab to each example.

Every link carries a full rounded border on a pseudo-element, clipped down to nothing. Hover opens the clip to a 2px strip along the bottom edge, so it looks like an underline, and the current page opens it all the way into a box. One clip-path transition covers both steps and no gradient is involved.

View code
.ba-nav {
  padding: 10px;
  border-radius: 16px;
  background: #18181b;
}
.ba-nav ul {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 4px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ba-nav a {
  position: relative;
  display: block;
  padding: 10px 16px;
  border-radius: 10px;
  color: #d4d4d8;
  font-weight: 600;
  text-decoration: none;
  transition: color .2s;
}
.ba-nav a::after {
  content: "";
  position: absolute;
  inset: 0;
  border: 2px solid #fbbf24;
  border-radius: 10px;
  pointer-events: none;
  clip-path: inset(calc(100% - 2px) 50% 0 50%);
}
.ba-nav a:hover,
.ba-nav a:focus-visible,
.ba-nav a[aria-current="page"] {
  color: #fff;
}
.ba-nav a:hover::after,
.ba-nav a:focus-visible::after {
  clip-path: inset(calc(100% - 2px) 12px 0 12px);
}
.ba-nav a[aria-current="page"]::after {
  clip-path: inset(0 0 0 0);
}
.ba-nav a:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ba-nav a::after {
    transition: clip-path .35s cubic-bezier(.3, .7, .2, 1);
  }
}

Borders that draw themselves

The line travels around the box instead of fading in. Two pseudo-elements with growing background layers, an SVG path with pathLength, and an SVG rect whose dashes stretch into a solid line.

Border that draws in from two corners

  • background-size
  • transition-delay
  • :focus-within

Case study

How Tidewater cut checkout time in half

A two-step form, saved addresses and one less page between the cart and the receipt.

Read the case study

Hover or tab to each example.

The top and bottom lines are two background layers on ::before, growing from opposite corners. The side lines live on ::after and start a quarter second later, so the stroke appears to run around the card. The delays swap in the base state, which makes the line retract in reverse order.

View code
.ba-draw {
  position: relative;
  width: min(320px, 100%);
  padding: 26px 24px;
  background: #fff;
  color: #292524;
  box-shadow: inset 0 0 0 1px #e7e5e4;
}
.ba-draw::before,
.ba-draw::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-repeat: no-repeat;
}
.ba-draw::before {
  background-image: linear-gradient(#0f766e 0 0), linear-gradient(#0f766e 0 0);
  background-position: left top, right bottom;
  background-size: 0 2px, 0 2px;
}
.ba-draw::after {
  background-image: linear-gradient(#0f766e 0 0), linear-gradient(#0f766e 0 0);
  background-position: right top, left bottom;
  background-size: 2px 0, 2px 0;
}
.ba-draw:hover::before,
.ba-draw:focus-within::before {
  background-size: 100% 2px, 100% 2px;
}
.ba-draw:hover::after,
.ba-draw:focus-within::after {
  background-size: 2px 100%, 2px 100%;
}
.ba-draw__kicker {
  margin: 0 0 6px;
  color: #0f766e;
  font-size: 14px;
  font-weight: 700;
}
.ba-draw__title {
  margin: 0 0 10px;
  color: #1c1917;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  line-height: 1.2;
}
.ba-draw__text {
  margin: 0 0 16px;
  color: #57534e;
  font-size: 15px;
  line-height: 1.5;
}
.ba-draw__link {
  color: #0f766e;
  font-weight: 700;
  text-underline-offset: 3px;
}
.ba-draw__link::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
}
.ba-draw__link:focus-visible {
  outline: none;
}
.ba-draw:has(.ba-draw__link:focus-visible) {
  outline: 3px solid #0f766e;
  outline-offset: 6px;
}
@media (prefers-reduced-motion: no-preference) {
  .ba-draw::before {
    transition: background-size .25s linear .25s;
  }
  .ba-draw::after {
    transition: background-size .25s linear;
  }
  .ba-draw:hover::before,
  .ba-draw:focus-within::before {
    transition-delay: 0s;
  }
  .ba-draw:hover::after,
  .ba-draw:focus-within::after {
    transition-delay: .25s;
  }
}

SVG stroke that traces a button

  • stroke-dashoffset
  • pathLength
  • pointer-events: none

Hover or tab to each example.

A rounded-rectangle path sits over the button with pathLength set to 100, so the dash values are plain percentages whatever the real perimeter is. The offset starts at 100, which shows only the gap, and transitions to 0 on hover or focus. The overlay ignores pointer events and matches the fixed button size.

View code
.ba-trace {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 220px;
  height: 56px;
  padding: 0;
  border: 0;
  border-radius: 14px;
  background: #0b1f1e;
  color: #ccfbf1;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: color .2s;
}
.ba-trace__svg {
  position: absolute;
  inset: 0;
  pointer-events: none;
  fill: none;
  stroke-width: 2;
}
.ba-trace__base {
  stroke: #1f4d49;
}
.ba-trace__line {
  stroke: #2dd4bf;
  stroke-dasharray: 100;
  stroke-dashoffset: 100;
}
.ba-trace__icon {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.ba-trace:hover,
.ba-trace:focus-visible {
  color: #fff;
}
.ba-trace:hover .ba-trace__line,
.ba-trace:focus-visible .ba-trace__line {
  stroke-dashoffset: 0;
}
.ba-trace:focus-visible {
  outline: 2px solid #ccfbf1;
  outline-offset: 5px;
}
@media (prefers-reduced-motion: no-preference) {
  .ba-trace__line {
    transition: stroke-dashoffset .7s cubic-bezier(.45, .05, .25, 1);
  }
}

Dropzone whose dashes stretch into a line

  • stroke-dasharray
  • SVG rect
  • dragenter

Hover or tab to each example.

A CSS dashed border cannot change its dash length, but an SVG rect can. The rect fills the dropzone at 100% width and height, and stroke-dasharray transitions from short dashes to long ones with hairline gaps when a file is dragged over or the zone is hovered or focused. The file name is written with textContent, and nothing is uploaded.

View code
.ba-drop {
  position: relative;
  display: grid;
  justify-items: center;
  gap: 4px;
  width: min(340px, 100%);
  padding: 30px 20px;
  border-radius: 16px;
  background: #f8faff;
  color: #1e293b;
  text-align: center;
  cursor: pointer;
  transition: background-color .2s;
}
.ba-drop__edge {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  fill: none;
  stroke: #94a3b8;
  stroke-width: 2;
  stroke-dasharray: 6 8;
  transition: stroke .2s;
}
.ba-drop__input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}
.ba-drop__icon {
  fill: none;
  stroke: #4338ca;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.ba-drop__title {
  font-weight: 700;
}
.ba-drop__title u {
  color: #4338ca;
  text-underline-offset: 3px;
}
.ba-drop__help {
  color: #475569;
  font-size: 14px;
}
.ba-drop__file {
  color: #3730a3;
  font-size: 14px;
  font-weight: 600;
  overflow-wrap: anywhere;
}
.ba-drop:hover,
.ba-drop.is-over,
.ba-drop:focus-within {
  background: #eef0ff;
}
.ba-drop:hover .ba-drop__edge,
.ba-drop.is-over .ba-drop__edge,
.ba-drop:focus-within .ba-drop__edge {
  stroke: #4338ca;
  stroke-dasharray: 40 1;
}
.ba-drop:has(:focus-visible) {
  outline: 3px solid #4338ca;
  outline-offset: 5px;
}
@media (prefers-reduced-motion: no-preference) {
  .ba-drop__edge {
    transition: stroke .2s, stroke-dasharray .45s cubic-bezier(.3, .7, .2, 1);
  }
}

Borders that report state

Focus, errors, progress and recording. The color or shape change carries the information and always happens, while the motion on top of it only runs when the visitor has not asked for reduced motion.

Focus ring that steps out of the input

  • outline-offset
  • outline-color
  • :focus-visible

Press / anywhere to jump here.

The outline is always there, transparent, sitting 2px inside the field over its border. On focus it turns violet and its offset moves to 4px outside, so the ring appears to step out of the input. Outline never takes layout space, so the label and the button beside it stay still.

View code
.ba-ring {
  width: min(340px, 100%);
  padding: 22px;
  border-radius: 16px;
  background: #fff;
  color: #1e1b4b;
}
.ba-ring__label {
  display: block;
  margin-bottom: 8px;
  font-weight: 700;
}
.ba-ring__row {
  display: flex;
  gap: 10px;
}
.ba-ring__input {
  flex: 1;
  min-width: 0;
  height: 44px;
  padding: 0 14px;
  border: 1px solid #a5a3c4;
  border-radius: 10px;
  background: #fff;
  color: #1e1b4b;
  font: inherit;
  outline: 2px solid transparent;
  outline-offset: -2px;
  transition: outline-color .15s, border-color .15s;
}
.ba-ring__input::placeholder {
  color: #64618a;
}
.ba-ring__input:hover {
  border-color: #6d28d9;
}
.ba-ring__input:focus {
  border-color: #6d28d9;
  outline-color: #6d28d9;
  outline-offset: 4px;
}
.ba-ring__btn {
  height: 44px;
  padding: 0 16px;
  border: 0;
  border-radius: 10px;
  background: #1e1b4b;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  outline: 2px solid transparent;
  outline-offset: -2px;
  transition: outline-color .15s;
}
.ba-ring__btn:focus-visible {
  outline-color: #6d28d9;
  outline-offset: 4px;
}
.ba-ring__hint {
  margin: 12px 0 0;
  color: #57557a;
  font-size: 14px;
}
@media (prefers-reduced-motion: no-preference) {
  .ba-ring__input,
  .ba-ring__btn {
    transition: outline-color .15s, outline-offset .25s cubic-bezier(.3, .7, .2, 1.4), border-color .15s;
  }
}

Error border with an opt-in shake

  • aria-invalid
  • translate keyframes
  • box-shadow ring

We send the invite to this address.

Submitting an invalid address sets aria-invalid, which turns the border and a box-shadow ring red and shows the message for everyone. Only visitors who allow motion also get a short sideways shake, which uses translate and therefore never moves anything around the field. The class is removed and re-added so the shake replays on every failed attempt.

View code
.ba-shake {
  display: grid;
  width: min(320px, 100%);
  padding: 22px;
  border-radius: 14px;
  background: #fff;
  color: #18181b;
}
.ba-shake__label {
  margin-bottom: 8px;
  font-weight: 700;
}
.ba-shake__input {
  height: 44px;
  padding: 0 14px;
  border: 1px solid #a1a1aa;
  border-radius: 8px;
  background: #fff;
  color: #18181b;
  font: inherit;
  box-shadow: 0 0 0 0 transparent;
  transition: border-color .15s, box-shadow .15s;
}
.ba-shake__input::placeholder {
  color: #6b6b74;
}
.ba-shake__input:focus-visible {
  border-color: #18181b;
  outline: none;
  box-shadow: 0 0 0 3px #a5b4fc;
}
.ba-shake__input[aria-invalid="true"] {
  border-color: #b91c1c;
  box-shadow: 0 0 0 3px #fecaca;
}
.ba-shake__msg {
  min-height: 1.4em;
  margin: 8px 0 14px;
  color: #52525b;
  font-size: 14px;
}
.ba-shake__input[aria-invalid="true"] + .ba-shake__msg {
  color: #b91c1c;
  font-weight: 600;
}
.ba-shake__btn {
  justify-self: start;
  height: 44px;
  padding: 0 20px;
  border: 0;
  border-radius: 8px;
  background: #18181b;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.ba-shake__btn:focus-visible {
  outline: 3px solid #6366f1;
  outline-offset: 3px;
}
@keyframes ba-shake {
  0%, 100% { translate: 0; }
  20% { translate: -7px; }
  40% { translate: 6px; }
  60% { translate: -4px; }
  80% { translate: 2px; }
}
@media (prefers-reduced-motion: no-preference) {
  .ba-shake__input.is-shaking {
    animation: ba-shake .4s ease-out;
  }
}

Card border that fills with progress

  • @property percentage
  • conic-gradient hard stop
  • padding-box / border-box

Finish your profile

1 of 4 done

A registered percentage drives a hard-stop conic gradient on the border-box layer, with a solid padding-box layer on top for the card fill. Ticking a task updates the custom property, and because it is registered as a percentage the browser can transition it, so the green arc slides around the edge. The count beside the title says the same thing in words.

View code
@property --ba-done {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}
.ba-prog {
  --ba-done: 25%;
  display: grid;
  gap: 4px;
  width: min(320px, 100%);
  padding: 20px 22px;
  border: 5px solid transparent;
  border-radius: 20px;
  background:
    linear-gradient(#fff 0 0) padding-box,
    conic-gradient(#15803d var(--ba-done), #e4e4e7 0) border-box;
  color: #14231a;
}
.ba-prog__head {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: baseline;
  gap: 4px 12px;
  margin-bottom: 8px;
}
.ba-prog__title {
  margin: 0;
  color: #14231a;
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
}
.ba-prog__count {
  margin: 0;
  color: #166534;
  font-size: 14px;
  font-weight: 700;
}
.ba-prog__task {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 36px;
  font-size: 15px;
  cursor: pointer;
}
.ba-prog__task input {
  width: 18px;
  height: 18px;
  margin: 0;
  accent-color: #15803d;
}
.ba-prog__task:has(:checked) {
  color: #52525b;
  text-decoration: line-through;
}
.ba-prog__task input:focus-visible {
  outline: 2px solid #15803d;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ba-prog {
    transition: --ba-done .6s cubic-bezier(.3, .7, .2, 1);
  }
}

Record button with a radius morph

  • border-radius
  • aria-pressed
  • scale

Tap to record

The red dot inside the ring morphs from a circle into a rounded square when recording starts, the shape most voice recorders use for stop. border-radius and scale do the work, the ring changes color, and the label text switches too, so the state is never carried by shape alone.

View code
.ba-rec {
  display: grid;
  justify-items: center;
  gap: 14px;
  padding: 10px;
}
.ba-rec__btn {
  display: grid;
  place-items: center;
  width: 84px;
  height: 84px;
  padding: 0;
  border: 4px solid #e4e4e7;
  border-radius: 50%;
  background: #1c1c1f;
  cursor: pointer;
  transition: border-color .2s;
}
.ba-rec__dot {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: #ef4444;
}
.ba-rec__btn[aria-pressed="true"] {
  border-color: #ef4444;
}
.ba-rec__btn[aria-pressed="true"] .ba-rec__dot {
  border-radius: 9px;
  scale: .55;
}
.ba-rec__btn:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 4px;
}
.ba-rec__label {
  margin: 0;
  color: #e4e4e7;
  font-size: 15px;
  font-weight: 600;
}
@media (prefers-reduced-motion: no-preference) {
  .ba-rec__dot {
    transition: border-radius .3s cubic-bezier(.3, .7, .2, 1), scale .3s cubic-bezier(.3, .7, .2, 1);
  }
}

Looping borders that know when to stop

Rotating, pulsing and marching borders that play a few cycles and stop well inside five seconds, or only run while the visitor hovers or asks for them. Hover a stopped one to replay it.

Featured card with a hard-stop conic border

  • @property angle
  • conic-gradient hard stops
  • finite iteration count

Most picked

Studio

$29 per month

  • Unlimited client projects
  • Custom domains and SSL
  • Priority support, 4 hour reply

Two gold segments with hard stops sit on a border-box conic layer, and a registered angle rotates them. The loop plays twice on load, 4.8 seconds in total, then stops on a finished-looking frame. Hover or focus inside the card switches to a second keyframes name with an infinite count, which the visitor started and can end by moving away.

View code
@property --ba-turn {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
.ba-conic {
  --ba-turn: 30deg;
  width: min(300px, 100%);
  padding: 24px;
  border: 2px solid transparent;
  border-radius: 20px;
  background:
    linear-gradient(#141210 0 0) padding-box,
    conic-gradient(from var(--ba-turn), #facc15 0 10%, #3a3631 10% 50%, #facc15 50% 60%, #3a3631 60%) border-box;
  color: #f5f5f4;
}
.ba-conic__tag {
  display: inline-block;
  margin: 0 0 12px;
  padding: 3px 10px;
  border-radius: 99px;
  background: #facc15;
  color: #1c1917;
  font-size: 13px;
  font-weight: 700;
}
.ba-conic__name {
  margin: 0;
  color: #fff;
  font-family: "Fraunces", Georgia, serif;
  font-size: 26px;
}
.ba-conic__price {
  margin: 4px 0 16px;
  color: #d6d3d1;
}
.ba-conic__price strong {
  color: #fff;
  font-size: 30px;
}
.ba-conic__list {
  display: grid;
  gap: 8px;
  margin: 0 0 20px;
  padding: 0 0 0 18px;
  color: #e7e5e4;
  font-size: 15px;
}
.ba-conic__list li::marker {
  color: #facc15;
}
.ba-conic__btn {
  width: 100%;
  min-height: 46px;
  border: 0;
  border-radius: 12px;
  background: #facc15;
  color: #1c1917;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .2s;
}
.ba-conic__btn:hover {
  background: #fde047;
}
.ba-conic__btn:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 3px;
}
@keyframes ba-turn {
  from { --ba-turn: 30deg; }
  to { --ba-turn: 390deg; }
}
@keyframes ba-turn-hover {
  from { --ba-turn: 30deg; }
  to { --ba-turn: 390deg; }
}
@media (prefers-reduced-motion: no-preference) {
  .ba-conic {
    animation: ba-turn 2.4s linear 2;
  }
  .ba-conic:hover,
  .ba-conic:focus-within {
    animation: ba-turn-hover 2.4s linear infinite;
  }
}

Live status ring on avatars

  • box-shadow ring
  • scale + opacity pulse
  • 3 iterations

The static red ring is a double box-shadow, and the LIVE chip says it in text. A pseudo-element copy of the ring scales up and fades three times, about four seconds, then stays still. Hovering or focusing an avatar replays the pulse for as long as the pointer or focus stays there.

View code
.ba-live {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 22px;
  margin: 0;
  padding: 18px 10px;
  list-style: none;
}
.ba-live li {
  display: grid;
  justify-items: center;
  gap: 12px;
}
.ba-live__item {
  position: relative;
  width: 72px;
  height: 72px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  cursor: pointer;
}
.ba-live__face {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background: var(--ba-face);
  color: var(--ba-ink);
  font-size: 20px;
  font-weight: 700;
}
.ba-live__item.is-live .ba-live__face {
  box-shadow: 0 0 0 3px #f1f5f9, 0 0 0 6px #e11d48;
}
.ba-live__item.is-live::before {
  content: "";
  position: absolute;
  inset: -6px;
  border: 3px solid #e11d48;
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
}
.ba-live__chip {
  position: absolute;
  bottom: -10px;
  left: 50%;
  translate: -50% 0;
  padding: 1px 7px;
  border: 2px solid #f1f5f9;
  border-radius: 6px;
  background: #be123c;
  color: #fff;
  font-size: 11px;
  font-weight: 800;
  letter-spacing: .06em;
}
.ba-live__name {
  color: #334155;
  font-size: 14px;
  font-weight: 600;
}
.ba-live__item:focus-visible {
  outline: 3px solid #1e293b;
  outline-offset: 10px;
}
@keyframes ba-live {
  from { opacity: .9; scale: 1; }
  to { opacity: 0; scale: 1.45; }
}
@keyframes ba-live-hover {
  from { opacity: .9; scale: 1; }
  to { opacity: 0; scale: 1.45; }
}
@media (prefers-reduced-motion: no-preference) {
  .ba-live__item.is-live::before {
    animation: ba-live 1.4s ease-out 3;
  }
  .ba-live__item.is-live:hover::before,
  .ba-live__item.is-live:focus-visible::before {
    animation: ba-live-hover 1.4s ease-out infinite;
  }
}

Marching ants crop selection

  • background-position
  • hard-stop tiles
  • box-shadow scrim
Gabled canal houses with small boats moored along a brick quay
Selection 1240 x 930

Each edge of the selection is a 12px tile of hard-stop black and white, repeated along one axis. Shifting all four background positions by exactly one tile makes the dashes crawl clockwise with no visible restart. The ants march eight times on load, four seconds, and again while the photo or its button has hover or focus.

View code
.ba-crop {
  width: min(320px, 100%);
  margin: 0;
  border-radius: 12px;
  background: #1e1e22;
  overflow: hidden;
}
.ba-crop__photo {
  position: relative;
  overflow: hidden;
}
.ba-crop__photo img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.ba-crop__sel {
  position: absolute;
  inset: 14% 18% 20% 22%;
  box-shadow: 0 0 0 999px rgba(10, 10, 12, .55);
  background:
    linear-gradient(90deg, #fff 50%, #111 0) 0 0 / 12px 1px repeat-x,
    linear-gradient(#fff 50%, #111 0) 100% 0 / 1px 12px repeat-y,
    linear-gradient(90deg, #fff 50%, #111 0) 0 100% / 12px 1px repeat-x,
    linear-gradient(#fff 50%, #111 0) 0 0 / 1px 12px repeat-y;
}
.ba-crop__bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 10px 12px 10px 16px;
  color: #d4d4d8;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
}
.ba-crop__btn {
  min-height: 36px;
  padding: 0 16px;
  border: 0;
  border-radius: 8px;
  background: #f4f4f5;
  color: #18181b;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.ba-crop__btn:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 3px;
}
@keyframes ba-ants {
  to { background-position: 12px 0, 100% 12px, -12px 100%, 0 -12px; }
}
@keyframes ba-ants-hover {
  to { background-position: 12px 0, 100% 12px, -12px 100%, 0 -12px; }
}
@media (prefers-reduced-motion: no-preference) {
  .ba-crop__sel {
    animation: ba-ants .5s linear 8;
  }
  .ba-crop:hover .ba-crop__sel,
  .ba-crop:focus-within .ba-crop__sel {
    animation: ba-ants-hover .5s linear infinite;
  }
}

Notification bell with an outline-offset pulse

  • outline-offset
  • animation-iteration-count: 3
  • aria-live
Fernhill

A pseudo-element ring animates outline-offset from 0 to 10px while its color fades, three times and then stops. Outline sits outside the box, so the header layout never shifts. A new message restarts the pulse by removing and re-adding the class, and the badge count and live text change whether motion is on or off.

View code
.ba-bell {
  display: grid;
  justify-items: center;
  gap: 14px;
  width: min(320px, 100%);
  padding: 16px 0 28px;
}
.ba-bell__bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  padding: 12px 14px 12px 18px;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 1px 2px rgba(15, 23, 42, .08);
}
.ba-bell__brand {
  color: #0f172a;
  font-family: "Fraunces", Georgia, serif;
  font-size: 19px;
  font-weight: 600;
}
.ba-bell__btn {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #f1f5f9;
  color: #0f172a;
  cursor: pointer;
  transition: background-color .2s;
}
.ba-bell__btn::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  outline: 2px solid transparent;
  outline-offset: 0;
  pointer-events: none;
}
.ba-bell__btn.is-new {
  background: #ffedd5;
}
.ba-bell__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.ba-bell__count {
  position: absolute;
  top: -2px;
  right: -4px;
  min-width: 20px;
  padding: 1px 5px;
  border: 2px solid #fff;
  border-radius: 99px;
  background: #c2410c;
  color: #fff;
  font-size: 11px;
  font-weight: 800;
  text-align: center;
}
.ba-bell__btn:focus-visible {
  box-shadow: 0 0 0 3px #fff, 0 0 0 5px #0f172a;
  outline: none;
}
.ba-bell__sim {
  min-height: 44px;
  padding: 0 18px;
  border: 1px solid #94a3b8;
  border-radius: 10px;
  background: #fff;
  color: #0f172a;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.ba-bell__sim:focus-visible {
  outline: 3px solid #c2410c;
  outline-offset: 2px;
}
.ba-bell__status {
  min-height: 1.4em;
  margin: 0;
  color: #334155;
  font-size: 14px;
}
@keyframes ba-bell {
  from { outline-color: #ea580c; outline-offset: 0; }
  to { outline-color: rgba(234, 88, 12, 0); outline-offset: 10px; }
}
@media (prefers-reduced-motion: no-preference) {
  .ba-bell__btn.is-new::after {
    animation: ba-bell 1.1s ease-out 3;
  }
}

Sync button with a rotating dashed ring

  • rotate pseudo-element
  • aria-busy
  • setTimeout reset

Last synced 2 hours ago

A dashed border cannot move its dashes, but the pseudo-element that carries it can rotate, and rotation is a transform the compositor handles. The ring spins four turns while the sync runs, 3.6 seconds, then the button returns to rest and the status line says when it finished. Under reduced motion the ring turns teal without spinning.

View code
.ba-sync {
  display: grid;
  justify-items: center;
  gap: 16px;
  padding: 14px;
}
.ba-sync__btn {
  position: relative;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 4px;
  width: 116px;
  height: 116px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #10201f;
  color: #ccfbf1;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.ba-sync__btn::before {
  content: "";
  position: absolute;
  inset: 0;
  border: 2px dashed #5b7774;
  border-radius: 50%;
  pointer-events: none;
  transition: border-color .2s;
}
.ba-sync__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.ba-sync__btn:hover::before {
  border-color: #99f6e4;
}
.ba-sync__btn[aria-busy="true"]::before {
  border-color: #2dd4bf;
}
.ba-sync__btn:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 5px;
}
.ba-sync__status {
  margin: 0;
  color: #d4d4d8;
  font-size: 14px;
}
@keyframes ba-sync {
  to { rotate: 1turn; }
}
@media (prefers-reduced-motion: no-preference) {
  .ba-sync__btn[aria-busy="true"]::before {
    animation: ba-sync .9s linear 4;
  }
}

Which border properties can CSS animate?

border-color and border-width interpolate between two values, and so do outline and box-shadow. border-style does not blend at all. A gradient animates only when a registered custom property drives it.

Properties that interpolate

  • border-color blends between two colors, including a fade from transparent.
  • border-width blends lengths, though every frame forces a layout pass.
  • outline-color, outline-width and outline-offset blend like their border counterparts and sit outside the box.
  • box-shadow blends offset, blur, spread and color.

MDN’s reference on animatable properties states that every CSS property animates unless its definition says otherwise.

Properties that flip instead of blend

border-style never blends. Going from solid to dashed swaps in one step, because no halfway value exists.

border-image-source behaves the same way. One gradient replaces another with no in-between frames, and MDN lists its animation type as discrete. Unregistered custom properties also jump from the start value to the end value.

Registered custom properties behave differently. MDN says they animate by the computed value type set in their syntax definition, which is why an angle in a plain –angle variable jumps while a registered one spins.

Outline and box-shadow as edge substitutes

outline-offset has worked in every major browser since April 2017, according to MDN.

That history makes outline the safest stand-in when a border has to grow without pushing neighbors aside. box-shadow does the same job with a spread value and no blur.

For triggers, :hover covers pointer users and :focus-visible covers keyboard users (MDN rates it Baseline Widely available). An animation that runs on page load needs no trigger at all.

How to animate a border on hover without layout shift

When only the color changes, a transition on border-color is enough. If the border has to grow, a border-width transition pushes neighboring elements around on every frame, so use outline or box-shadow instead. A scaled pseudo-element works too.

Transition on border-color

Put the transition on the base state, not on :hover. A transition declared only inside :hover animates the change in and snaps back on the way out.

A declaration like transition: border-color 0.3s ease covers most cards and buttons. Tune the duration and easing curve in the browser DevTools before you commit them.

Mirror every hover rule on :focus-visible. A hover-only border leaves keyboard users without the cue.

Buttons and cards take the same pattern, and so do links. A roundup of CSS hover effects shows a variation for each.

Layout-safe alternatives to border-width

Changing border-width triggers layout, and nearby elements shift unless the element has an explicit size, as CSS-Tricks documented in its Animating Border test.

  • Outline grows outside the box and leaves layout alone. The original test recorded a rendering artifact in Safari, so check current builds.
  • A box-shadow with a 0 0 0 10px spread mimics a thicker border. It costs paint time, not layout time.
  • A scaled pseudo-element animates transform instead of size. Per CSS-Tricks, the side edges look thicker than top and bottom until the animation finishes.

The pseudo-element wins on cost, since web.dev’s rendering guide names transform and opacity as the only two properties that change without layout or paint. The catch is the uneven edges and the fixed starting width.

CSS-Tricks also found that a content-sized button cannot grow its border from exactly 5px to 10px with the pseudo-element method, because the button’s dimensions vary with its text. For those elements, outline is the better default.

How to build looping border animations with @keyframes

Looping borders get their steps from @keyframes, and animation-duration, animation-timing-function and an infinite animation-iteration-count attach them to the element. A loop repaints the border on every frame, so keep the animated area small.

A deeper guide on writing CSS keyframes covers percentage selectors and fill modes, plus staggered animation-delay values.

Dashed border that moves

Marching dashes come from background layers, not from border-style, since a dashed border cannot shift its dash pattern. Stack four repeating-linear-gradient layers along the four edges, then animate background-position in @keyframes.

The gradient stops set the dash length and the gap. Shift by exactly one dash period per loop and the cycle restarts without a visible jump.

Glowing border with box-shadow

Alternate between two box-shadow values with animation-direction: alternate to pulse a glow. Use the same number of shadow layers in both keyframes so the interpolation pairs them one to one.

Colt McAnlis’s paint-time tests on web.dev found that cost depends on the specific value combination of box-shadow and border-radius, not on the property alone. Those tests ran on a Chrome 27 beta, so read them as direction, not as a benchmark.

Paint flashing in the Chrome DevTools Rendering tab shows what a given glow repaints on a real page. Glow size and blur radius are the first values to test on a slow phone, and a visual box-shadow tool makes stacking the layers quicker through a box-shadow generator.

Running light along the edge

A DEV Community walkthrough by Simon Bittok rebuilds the moving green lines first spotted on deno.com feature cards.

The ::before pseudo-element carries a conic-gradient, and its padding becomes the visible border. ::after adds a softer glow around it.

overflow: hidden on the card clips any glow that extends past the edge, and border-radius: inherit keeps the light inside rounded corners. Watch the z-index stacking too, because it can drop the pseudo-element behind the card background.

How to create an animated gradient border with @property

The angle gets registered with @property, a conic-gradient reads it on a border-box background layer, and @keyframes animate the property from 0deg to 360deg. The browser interpolates the angle, so the gradient rotates around the edge.

As of October 2026, web-features data lists @property as Baseline Newly Available since 2024-07-09, the day Firefox 128 shipped. Chrome 85 and Safari 16.4 support it earlier.

  1. Register the angle by declaring @property –angle with syntax ““, inherits: false and initial-value: 0deg.
  2. Set a transparent border. Pick the border width, add border-radius, and leave the color transparent.
  3. Stack two backgrounds. Paint the solid fill with the padding-box value and a conic-gradient(from var(–angle), …) with the border-box value.
  4. Animate the property. Write @keyframes that end at –angle: 360deg, then apply them with a linear timing function and an infinite iteration count.

Background layers with conic-gradient

Jason Lengstorf’s walkthrough explains why conic works here: the gradient sits at the center of the box, so the parts visible in the border appear to follow the edge.

A linear gradient smears at the corners instead. Stops are quicker to pick in a gradient generator for CSS.

Without @property support, the angle cannot interpolate and the border stays a static gradient. That static state is the fallback, so design it to look finished on its own.

border-image and its radius limit

The border-image route works with border-image: conic-gradient(from var(–angle), …) 1. It stays rectangular though, because MDN states that border-radius has no effect on a border image. Add the fill slice keyword and it also paints the middle of the box, over the background.

Background layers do respect border-radius and keep the card fill opaque, at the cost of the two-layer padding-box and border-box setup.

For a transparent center, cut a gradient pseudo-element with mask and mask-composite. Temani Afif’s glowing border demo on css-tip.com uses a subtract mask driven by a registered angle, and MDN lists mask-composite as Baseline since December 2023 (Widely available since June 2026).

Each frame of the spin repaints the gradient layer, because only transform and opacity change without paint. Keep the number of spinning borders per page low.

Drawing a border line by line with SVG and clip-path

Animate stroke-dashoffset on an SVG rect so the border draws itself, or animate a clip-path polygon to reveal a CSS border in stages. SVG gives a true tracing line. clip-path keeps everything in plain CSS.

SVG stroke-dashoffset

MDN lists stroke-dashoffset as Baseline Widely available, working across browsers since April 2017, so the technique carries no support risk.

Set stroke-dasharray and stroke-dashoffset to the path length, then animate the offset to 0. The dash starts as a gap and the line appears to draw itself, as Jake Archibald’s line-drawing walkthrough shows. The length itself can be read from the DOM.

MDN notes that the offset resolves against pathLength when that attribute is set, which lets one set of dash values fit any rect size. Timing and easing for these strokes are covered in a guide on how to animate SVG with CSS.

The SVG overlay needs pointer-events: none, otherwise it blocks clicks on the element underneath. It also has to match the box size, which gets tricky when content changes the box.

clip-path reveal

A full border sits under a clip-path built with calc values that trim it down. On hover, a transition widens the clip until the whole border shows. CSS-Tricks credits Steve Gardner with the first implementation, in its Animating Border test.

Polygon points are easier to place visually with a clip-path tool.

Four-segment pseudo-element draw

::before draws the top and right edges, and ::after takes the bottom and left. Widths and heights transition to 100% on hover, and staggered transition-delay values make the line travel around the box.

Equal durations per segment keep the line moving at a constant speed. Unequal ones make the corners feel like stops.

Which CSS border animation method should you pick?

A color change only needs a border-color or outline transition. A moving gradient calls for @property with conic-gradient, and a line that draws itself is a job for SVG or clip-path. Whichever effect you want, pick the cheapest method that produces it.

MethodCost per frameFits rounded cornersMain catch
border-color transitionPaintYesChanges color only
box-shadow spreadPaintYesCost depends on blur and radius values
Scaled pseudo-elementCompositeYes, with a matching radiusUneven edge thickness mid-scale
@property conic-gradientPaintYesStatic gradient without @property
SVG stroke-dashoffsetPaintSet rx on the rectOverlay must match the box size

How to check what an animation costs

According to Chrome for Developers, the Lighthouse audit Avoid non-composited animations lists every DOM node whose animation could not be composited, with the failure reason. Chrome writes those reasons into the DevTools trace, and Lighthouse reads that trace. Non-composited animations that move elements also add to Cumulative Layout Shift, while composited ones are excluded from it.

Chrome defines a non-composited animation as any animation that triggers style, layout or paint. That flags border-color and box-shadow animations too, because both trigger paint.

A flagged hover transition on one button is normal. A flagged infinite loop on a grid of cards is the finding to act on.

A default that holds up for most components

For most components, a border-color or outline transition on hover and focus is plenty. Stop there unless the design needs more.

Hover and focus borders are small micro-interactions, and they read best when subtle. Save the rotating gradient for one or two elements per page.

Buttons and links get a border-color or outline transition. On pricing or feature cards, put the gradient border on the highlighted card only. A logo or signature mark can take an SVG stroke draw, once, on load.

Browser support for animated borders and fallbacks

Transitions and @keyframes on border-color, outline and box-shadow carry no support risk in current browsers. The gradient border driven by @property is the part to check, and its fallback is a static gradient border.

Every figure below is dated October 2026.

  • prefers-reduced-motion has been available across browsers since January 2020 (MDN), with Widely Available status dated 2022-07-15 (web-features).
  • conic-gradient() has worked across browsers since November 2020 (MDN), the month Firefox 83 added it (Mozilla Hacks), and has been Baseline Widely available since May 2023 (web-features).
  • border-image-source has been available across browsers since July 2015 (MDN).
  • @property should reach Widely Available status on 2027-01-09 (web-features).

The two prefers-reduced-motion dates do not conflict. Baseline marks Widely Available 30 months after a feature lands in every major engine, which is why July 2022 trails January 2020.

Fallback for browsers without @property

Declare the gradient border in its static state first, then layer the animation on top. A browser without @property still paints a finished border.

The unregistered angle animates discretely, and 0deg and 360deg draw the same gradient, so the border looks static instead of broken.

Older tutorials say otherwise about Firefox. Cruip’s Tailwind walkthrough builds its card from a three-stop linear gradient and a five-stop conic gradient, then states that Firefox lacks @property.

That note is outdated. Treat any tutorial listing Firefox as unsupported as written before the feature shipped.

Testing across engines

Check the animated state, where the spin runs and the loop restarts without a jump. Then check the static state, where the border still has to read correctly with animation set to none.

Run both states in Chromium, Firefox and Safari with reduced motion on and off. The wider device matrix is covered in a guide to cross-browser compatibility.

Accessibility rules for animated borders

Looping border animation needs a prefers-reduced-motion check. Auto-running loops should stop within five seconds or come with a pause control, and the border itself needs 3:1 contrast against its neighbors. Hover transitions fall under a different criterion than loops.

Using prefers-reduced-motion

One way is to define the animation, then set animation: none inside @media (prefers-reduced-motion: reduce). The other starts from static styles and adds the motion only inside @media (prefers-reduced-motion: no-preference).

W3C’s technique C39 documents both. The second fails safe, because a browser that cannot evaluate the query never sees the motion rules.

Keep the state cue. A hover border that vanishes under reduced motion removes information, so swap the animation for an instant color change instead of deleting the effect.

WCAG criteria that apply to border motion

CriterionLevelBorder effect it covers
2.2.2 Pause, Stop, HideALoops that start on their own, run past five seconds and sit beside other content
2.3.3 Animation from InteractionsAAAHover, focus and click effects that move
1.4.11 Non-text ContrastAABorders and focus indicators that identify a component or its state

W3C’s guidance for 2.2.2 treats content that stops within five seconds as compliant. A gradient border set to one finite turn, such as animation: spin 4s linear 1, finishes under that limit and needs no pause control.

W3C itself does not recommend requiring Level AAA conformance as a general policy for entire sites. The prefers-reduced-motion technique still costs only a few lines of CSS.

Contrast of the border itself

WCAG 1.4.11 sets 3:1 as the minimum contrast against adjacent colors for visual information that identifies a component or its states (W3C, Understanding SC 1.4.11).

A rotating gradient passes through every stop, so test the weakest stop against the adjacent background, not the average. A ratio check goes faster with a guide to color contrast open beside the design.

W3C also warns that thin lines can render fainter than their CSS color because of anti-aliasing, so a nominal pass can fail in practice. When the ratio sits near the limit, use a thicker border or a darker stop.

Test each gradient stop, and test hover and focus states separately. Then run the final result through a web accessibility checklist before release.

Why a CSS border animation does not work

When a border animation does nothing, the cause is usually a bad @property rule, a border with nothing to paint, a pseudo-element hidden behind an ancestor background, or a reduced-motion setting on the developer’s own machine. Each one has its own symptom and fix.

Declaration and registration errors

  • An invalid @property rule gets ignored whole. MDN states that syntax and inherits are required, and initial-value is required unless the syntax is the universal one. If either is missing or invalid, the angle never interpolates.
  • animation-duration defaults to auto, which MDN equates to 0s for time-based animations, so an animation shorthand without a time never visibly runs.
  • A border with no style paints nothing, so a color or width transition has no visible result.
  • Your own system setting can switch reduce motion on, which triggers the reduced-motion block and silently disables the animation under test. MDN lists the settings that turn it on in Windows, macOS, iOS, Android and Linux desktops.

Stacking and visibility problems

A pseudo-element with a negative z-index joins the nearest stacking context. With none on an ancestor, it falls behind every ancestor background and disappears.

Give a wrapper isolation: isolate. According to makandra’s z-index write-up, that creates a stacking context with no other side effects, so the pseudo-element sits above the wrapper and below the card content.

Leave the card without a z-index of its own. The pseudo-element gets z-index: -1 with a negative inset, so it shows as a rim around the card.

When to drop the animation

Some effects are not worth fixing.

A grid of looping gradient cards repaints on the main thread every frame, which is the work Lighthouse’s audit counts as non-composited. One static gradient costs nothing per frame. Honestly, a looping gradient on every card is more than most pages need.

If the border carries information (selected, error, focus), animation is decoration on top of the signal. Keep the static state as the source of truth and animate only the arrival.

CSS Border Animation FAQ

Can border animation work without JavaScript?

Without any script, yes. Transitions and @keyframes handle hover borders and looping glows, and @property covers rotating gradients.

Script appears only when measuring an SVG path length, and setting pathLength on the shape removes that need.

How do you pause a border animation on hover?

Set animation-play-state: paused inside the :hover rule. The border freezes at its current angle or position and resumes from there when the pointer leaves.

Flip the logic for effects that should only move on interaction. Paused by default, running on hover.

Is border-radius animatable?

It interpolates like any length or percentage, so a square corner can round on hover.

A border-image gradient will not follow the change, because it stays rectangular. Gradient borders built on background layers do follow it.

Does a border animation follow scroll position?

Through CSS scroll-driven animations, it can. The animation-timeline property ties keyframe progress to scroll offset or to an element’s visibility instead of to time.

Chrome 115 and Safari 26 ship them, and Firefox 159 turns them on by default in October 2026. Support is still newer than for transitions, so keep a static border as the fallback. More scroll-linked patterns are collected in a guide to CSS scroll effects.

How do you animate a border with Tailwind CSS?

Utility classes handle the simple cases, meaning a color transition plus a hover variant of the border color class. Rotating gradient borders also need an @property rule in plain CSS and a custom keyframe animation registered in the theme configuration.

A Tailwind CSS guide covers the setup before you add either.

When should the Web Animations API replace CSS keyframes?

Switch when the border needs runtime control, meaning starting, reversing, seeking or chaining animations from script, or reading progress while it plays. MDN lists the Animation interface as Baseline Widely available since March 2020.

For fixed hover and loop effects, plain CSS stays simpler.

Which CSS Border Animation to Ship First

A border-color or outline transition belongs in production first, because it needs no registration and degrades to an instant color change under reduced motion.

After that, the order costs least at each stage and leaves a releasable result after every one. Start with the hover and focus transition, with :focus-visible parity, and test the reduced-motion rule with the system setting on. Add one @property gradient border last, static fallback first.

The trade-off is that this order gives up continuous motion on most components and spends paint time only where the gradient runs.

Verified October 2026, the sequence changes once @property reaches Widely Available status, because the fallback then guards only older browsers.

The same transition and keyframe tools apply to other properties, covered in a broader guide to CSS animation.