9 CSS Border Animations — One Line, Copy-Paste
A css border animation moves the edge of a card, button, or input instead of its fill, so it can signal loading, focus, or selection without shifting anything
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Diagonal highlight sweep border
- 02 SVG border draw
- 03 Dashed line march
- 04 Glow pulse border
- 05 Corner brackets
- 06 Underline grow
- 07 Double ring border
- 08 Rainbow border
- 09 Animated focus ring
The nine below run in the order a border tends to earn attention in a real interface. 01, 03, and 04 stay in motion on their own, standing in for something still processing, while 02 draws itself in once, confirming a card loaded or a form went through. 05 and 06 react to a person doing something — hovering a tile, focusing a field. 07 is a one-shot hover flourish, 08 marks a moment worth noticing, and 09 closes on the one border that exists purely for keyboard users. Play with the grid above, then pull whichever item earns its spot out of the zip below.
01Diagonal highlight sweep border
A ring cut to the exact border shape carries a diagonal striped gradient, and sliding its background-position on a loop makes the whole frame read as lit and flowing at once rather than one hotspot circling the edge. It fits an input field an AI answer is still filling, or a premium card frame that wants constant motion without pulling focus to one spot.
.frame::before {
content: "";
position: absolute;
inset: 0;
padding: 3px;
background: repeating-linear-gradient(115deg, $color 0 10px, $subject-cream 10px 14px, transparent 14px 34px);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.frame.is-demo::before { animation: sweep $dur-loop linear infinite; }
@keyframes sweep { to { background-position: 220px 0; } }
02SVG border draw
The card's outline is an SVG rect given pathLength="100", so stroke-dashoffset always counts down from 100 to 0 no matter the rect's actual perimeter in pixels. Looping that offset from 100 to 0 and back draws the border in like a pen retracing the same line, which fits a card confirming it loaded or a form confirming it submitted.
.card__rect {
fill: none;
stroke: $color;
stroke-width: 3;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.card.is-demo .card__rect { animation: draw-loop $dur-loop $ease-out infinite; }
@keyframes draw-loop {
0%, 5% { stroke-dashoffset: 100; }
35%, 60% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
03Dashed line march
Each of the four edges carries its own dashed gradient background, and sliding background-position along that specific edge's own axis — not one shared direction for all four — makes the dashes appear to march clockwise around the whole box together. It reads well on a card mid-upload, standing in for a percentage nobody wants to watch tick by.
.frame__edge--top, .frame__edge--bottom {
background-image: repeating-linear-gradient(90deg, $color 0 8px, transparent 8px 16px);
}
.frame__edge--left, .frame__edge--right {
background-image: repeating-linear-gradient(180deg, $color 0 8px, transparent 8px 16px);
}
.frame.is-demo .frame__edge--top { animation: run-x $dur-loop linear infinite; }
.frame.is-demo .frame__edge--bottom { animation: run-x-rev $dur-loop linear infinite; }
@keyframes run-x { to { background-position: 32px 0; } }
@keyframes run-x-rev { to { background-position: -32px 0; } }
04Glow pulse border
A --glow custom property, registered through @property as a <length>, animates between 0px and 16px, and once the browser knows its type it moves the box-shadow blur radius through every step in between rather than jumping straight from one extreme to the other. That steady middle is what makes it worth using on a badge or status card that needs to breathe on its own.
@property --glow {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.badge {
box-shadow: 0 0 var(--glow) 0 rgba($color, .55);
}
.badge.is-demo { animation: glow $dur-loop $ease-spring infinite; }
@keyframes glow { 0%, 100% { --glow: 0px; } 50% { --glow: 16px; } }
05Corner brackets
Four independent L-shaped brackets sit at the corners, each pushed outward along its own diagonal by a plain transform: translate on hover or keyboard focus. Opening all four at once reads as a camera focus frame snapping shut on a subject, which is why it fits the tile a person just picked in an image gallery.
.frame__bracket--tl { top: -3px; left: -3px; border-right: 0; border-bottom: 0; }
.frame__bracket--tr { top: -3px; right: -3px; border-left: 0; border-bottom: 0; }
.frame:hover .frame__bracket--tl,
.frame:focus-visible .frame__bracket--tl { transform: translate(-6px, -6px); }
.frame.is-demo .frame__bracket--tl { animation: open-tl $dur-loop $ease-pop infinite; }
@keyframes open-tl {
0%, 15% { transform: translate(0, 0); }
50%, 70% { transform: translate(-6px, -6px); }
100% { transform: translate(0, 0); }
}
06Underline grow
The bar under the field starts collapsed at the center with left: 50%; right: 50%, and focusing the input transitions both properties to 0 so the bar fills outward from the middle instead of sliding in from one side. Using left and right instead of scaleX keeps that centered fill without a separate transform-origin line anywhere in the rule.
.field__bar {
position: absolute;
left: 50%; right: 50%; bottom: -2px;
background: $color;
transition: left $dur-quick $ease-out, right $dur-quick $ease-out;
}
.field__input:focus + .field__bar { left: 0; right: 0; }
07Double ring border
A fixed inner ring comes from a plain box-shadow on the button itself, while a second ring built on ::after stays invisible at rest and only scales up while fading on hover or focus, reading as one ripple leaving the button behind. It works as a hover cue for a button that needs more presence than a color swap alone gives it.
.btn { box-shadow: 0 0 0 3px rgba($color, .9); }
.btn::after {
content: "";
position: absolute;
inset: -3px;
border: 2px solid $color;
opacity: 0;
transform: scale(1);
transition: transform $dur-base $ease-out, opacity $dur-base $ease-out;
}
.btn:hover::after, .btn:focus-visible::after { opacity: 1; transform: scale(1.3); }
08Rainbow border
border-image-source carries one fixed rainbow gradient that never moves, and the only property animating is filter: hue-rotate, turning from 0deg to 360deg so the colors cycle in place instead of sliding along the edge the way item 01's sweep does. It suits an event banner or a small celebration badge, where the whole frame should feel festive without competing with the sweep pattern above.
.badge {
border: 4px solid;
border-image-slice: 1;
border-image-source: linear-gradient(90deg, $error, $stage-orange, $stage-yellow, $subject-blue, $error);
}
.badge.is-demo { animation: hue $dur-loop linear infinite; }
@keyframes hue { to { filter: hue-rotate(360deg); } }
09Animated focus ring
outline sits fully transparent at rest, and the moment keyboard focus lands, outline-color fades in while outline-offset transitions from 0 to 4px, so the ring pops outward and settles instead of just snapping into view. Because the rule only fires on :focus-visible, a mouse click never triggers it — only Tab, arrow keys, or any other keyboard-driven focus does.
.field__input {
outline: 2px solid transparent;
outline-offset: 0;
transition: outline-color $dur-quick $ease-out, outline-offset $dur-quick $ease-out;
}
.field__input:focus-visible { outline-color: $color; outline-offset: 4px; }
Where it breaks — the trap
Item 04's glow only reads as a pulse because @property tells the browser --glow is a <length> before the box-shadow rule ever touches it. Drop that registration and animate the bare custom property instead, and a browser has no declared type to interpolate between — the blur radius jumps straight from 0px to 16px with no steps in between, since an unregistered custom property is treated as a plain string, not a number a browser can tween — the same way an unregistered custom color would refuse to fade smoothly between two hex values. MDN's @property reference covers exactly which browser versions accept the registration and which fall back to the string behavior instead, which matters most for anyone still shipping this badge to an older Safari or Firefox build. The archive keeps that registration in item 04's own file, and the whole set unlocks with 4vruzyhz, copied straight from this sentence — nothing added, nothing dropped.
Item 01 leans on a second, older trap: its ring only stays cut out because two mask declarations sit side by side, -webkit-mask-composite: xor for WebKit's own compositing keyword and mask-composite: exclude for the standard property that replaced it. The two don't share values — xor isn't a legal value for the unprefixed property at all, per MDN's mask-composite page — and support for the unprefixed form still lags well behind the -webkit- line it stands next to, so dropping either declaration quietly reverts the ring to a solid filled box on whichever engine only reads the one that's missing. Item 08's filter: hue-rotate has its own cost worth flagging separately: filter repaints every child inside the element it's applied to, so a badge with an icon and label stays cheap, but stretching the same rule over a large card with lots of nested content repaints far more than the border alone needs.
Accessibility
prefers-reduced-motion: reduce sits on all nine, and most of them respond the same way: 01, 03, 04, and 08 freeze their looping background or glow at a single resting frame instead of continuing to cycle. 02 skips straight to its finished state — the border fully drawn — rather than animating there. 05 only turns its own auto-loop off; the brackets rest closed until hovered or focused, when the corner transition still opens them with full easing. 07's ripple simply stops firing on hover, leaving only the fixed inner ring behind. 06 and 09 are the two exceptions worth calling out: their transitions still respond to focus, just without easing, because a focus indicator that vanished under reduced motion would leave a keyboard user with no way to see where they landed at all.
@media (prefers-reduced-motion: reduce) {
.frame.is-demo::before, .badge.is-demo, .card.is-demo .card__rect { animation: none; }
}
The rest of this site's motion work lives on the CSS category hub, and what this project does and doesn't sell is on the about page.
FAQ
Is a css border animation the same thing as an outline animation?
Not quite. outline draws outside the box and never affects layout, which is why item 09 leans on it for a focus ring that has to appear and disappear without nudging anything. The other eight animate an actual border, a box-shadow, a border-image, or a mask cut to the border's shape, and several of those do take up layout space unless they're pulled out with position: absolute the way items 01, 03, 05, and 07 are here.
Do any of these nine need JavaScript to run?
Mostly not. Eight of the nine drive their motion from CSS alone — :hover, :focus, :focus-visible, or a plain infinite @keyframes loop for .is-demo. Item 02 is the exception: its one-shot draw-in needs JavaScript to add the .is-drawn class once the card loads, though its own .is-demo loop above still plays with CSS alone. The react/ folder just wraps each one in a component so the loop can be toggled from props.
Which of these nine is safest to leave running on a page nobody is actively watching?
06 and 09, since both sit completely still until someone actually focuses the field — nothing moves until that happens. 01, 03, 04, and 08 are the four built to loop on their own, so keep at most one of those visible per screen; several looping borders competing for attention at once mostly tell a visitor where not to look.