Animated gradient borders: three CSS techniques

border-image, clipped backgrounds and a conic pseudo-element — which ones handle rounded corners, how to make them move, and what each costs.

CSS recipe · By AurumCodeWorks · 7 min read · Updated 12 October 2026

A gradient border looks as though it should be one property, and it isn't. border-color only accepts colours, so there is no border-color: linear-gradient(…). You have to borrow another feature — the border image, the background, or a pseudo-element — and that choice decides whether rounded corners work, whether the inside can stay transparent, and how much the animation costs each frame. Here are the three techniques running live, then the exact CSS behind each.

Square corners
1. border-image — static; border-radius: 14px is set but ignored
2. Clipped backgrounds — rounded, spun with an @property angle, solid focus ring
Rotating glow
3. Conic pseudo-element — rounded, rotated with transform, static glow

All three use the same stops — violet #8b5cf6, cyan #06b6d4 and emerald #10b981 — which you can rework in our CSS gradient generator before pasting them in. If what you actually want is a gradient fill that shifts on hover rather than a border, the Gradient Shift card in our hover effects library is a simpler starting point.

Technique 1: border-image

border-image was designed to slice an image into nine pieces and paint them into the border area. As far as CSS is concerned a gradient is an image, so you can pass one in directly:

.gb-image {
  padding: 18px 26px;
  border: 4px solid;
  border-image: linear-gradient(90deg, #8b5cf6, #06b6d4, #10b981) 1;
  border-radius: 14px; /* has no effect on the border image */
  color: #f8fafc;
  font-weight: 600;
}

The trailing 1 is border-image-slice. Leave it out and the default slice of 100% hands the whole gradient to the four corner regions, leaving the edges empty. You still need a border-style and width, because the image is drawn into the border area and an element with no border has none.

The catch is spelled out in the MDN reference for border-image: border-radius has no effect on the border image. A border image can be pushed outside the border box with border-image-outset, so clipping it to the curve wouldn't make sense. The first demo sets a 14px radius and the corners stay square. Worse, the radius still rounds any background or box-shadow on the same element, so mixing them looks broken.

That makes border-image the right tool for square-cornered things: callout bars, table headers, or a single accent edge with border-width: 0 0 0 4px. It's also the only technique here that leaves the element's interior completely untouched, so photos, patterns and backdrop filters behind it show through. MDN lists it as widely available since July 2015.

Technique 2: two backgrounds, two clip boxes

For rounded corners, draw the border with the background instead. Make the border transparent so the background shows through it, then stack two layers: a solid fill clipped to the padding box on top, and the gradient clipped to the border box underneath. The gradient is only visible in the ring the fill doesn't cover, and because backgrounds follow border-radius, the ring curves with the corners. MDN offers this same pattern as its workaround on the border-image page.

@property --gb-angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}
.gb-clip {
  padding: 16px 26px;
  border: 3px solid transparent;
  border-radius: 14px;
  background:
    linear-gradient(#0b1222, #0b1222) padding-box,
    conic-gradient(from var(--gb-angle, 0deg), #8b5cf6, #06b6d4, #10b981, #8b5cf6) border-box;
  animation: gb-spin 4s linear infinite;
  color: #f8fafc;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.gb-clip:focus-visible {
  outline: 2px solid #f8fafc;
  outline-offset: 4px;
}
@keyframes gb-spin {
  to { --gb-angle: 360deg; }
}

The keyword after each layer does double duty. In the background shorthand, a single box keyword sets both background-origin and background-clip. The origin matters: it defaults to the padding box, so without border-box the gradient would be sized to the inside and tile underneath the border, leaving a visible seam along the edges.

The fill is written as linear-gradient(#0b1222, #0b1222) on purpose. A background colour can only belong to the bottom layer, so a solid fill that sits on top of another layer has to be expressed as a one-colour gradient image.

The limitation: the top layer must be opaque and match the element's intended interior, so this can't give you a see-through card over a photo. The inner curve is automatic — 11px here, the 14px radius minus the 3px border.

Technique 3: a conic-gradient pseudo-element

For a glow or a radar-style sweep, move the gradient onto its own layer. The third demo puts an oversized square ::before behind the content, fills it with a conic-gradient, and covers the middle with an ::after inset by the border width:

.gb-glow {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  padding: 18px 26px;
  border-radius: 14px;
  box-shadow: 0 0 22px rgba(6, 182, 212, 0.25);
  color: #f8fafc;
  font-weight: 600;
}
.gb-glow::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 150%;
  aspect-ratio: 1;
  z-index: -2;
  background: conic-gradient(#8b5cf6, #06b6d4, #10b981, #8b5cf6);
  transform: translate(-50%, -50%) rotate(0turn);
  animation: gb-rotate 4s linear infinite;
}
.gb-glow::after {
  content: "";
  position: absolute;
  inset: 3px;
  z-index: -1;
  border-radius: 11px;
  background: #0b1222;
}
@keyframes gb-rotate {
  to { transform: translate(-50%, -50%) rotate(1turn); }
}

The parent's overflow: hidden and border-radius trim the spinning square into a rounded ring. isolation: isolate creates a stacking context, so the negative z-index layers stay inside the element instead of dropping behind the page. The square is 150% of the element's width so that its corners always cover the element's corners mid-turn; for an element taller than it is wide, size it from the height instead, or you'll see empty corners flicker past. The glow is a static box-shadow.

If the interior must stay transparent, replace the cover layer with a mask. This variant isn't one of the demos above; it uses a single ::before with padding equal to the border width and the same gb-spin keyframes:

.gb-mask {
  position: relative;
  border-radius: 14px;
}
.gb-mask::before {
  content: "";
  position: absolute;
  inset: 0;
  padding: 3px; /* border thickness */
  border-radius: inherit;
  background: conic-gradient(from var(--gb-angle, 0deg), #8b5cf6, #06b6d4, #10b981, #8b5cf6);
  mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
  animation: gb-spin 4s linear infinite;
  pointer-events: none;
}

The first mask layer covers only the content box and the second covers everything; exclude keeps the areas where exactly one layer is present, which is the padding ring. The MDN page for mask-composite shows it available across browsers since December 2023, so the prefixed -webkit-mask-composite: xor in older tutorials is only needed for browsers from before then.

Making it move: @property or transform

Gradients can't be interpolated. A conic-gradient() is an image, background-image animates discretely, so keyframes that swap one gradient for another snap rather than tween. An ordinary custom property can't help either: until it's registered, the browser treats its value as a string of tokens with no type to interpolate.

Registering it with @property gives it one. Declared as an <angle>, --gb-angle tweens from 0deg to 360deg, and the gradient that reads it is recomputed each frame. The MDN reference for @property marks it Baseline 2024, working across the latest browsers since July 2024. Both syntax and inherits are required, as is initial-value for any syntax other than *; leave one out and the whole rule is ignored. inherits: false keeps the angle from cascading into the button's children.

Note the fallback in var(--gb-angle, 0deg). In a browser without @property the property has no value outside the keyframes, and without a fallback the whole background declaration would be invalid at computed-value time: no fill and no border. With it, older browsers get a static gradient. The same approach animates technique 1 too, since border-image accepts a gradient that reads a variable.

The other option is never to animate the gradient at all. Technique 3 paints its conic gradient once and spins the layer with transform: rotate(). For a conic sweep the result looks almost identical, but it only works when the gradient lives on a layer that can turn by itself — you can't rotate a border image or one background layer independently of the element's content.

The three techniques compared

TechniqueRounded cornersAnimatable howExtra elementSupport / caveats
border-imageNo — border-radius is ignored@property angle inside the gradient (repaints)NoneWidely available since 2015; interior stays transparent; needs a border style and the 1 slice
Clipped backgroundsYes@property angle (repaints)NoneInner fill must be an opaque colour that matches the design; @property is Baseline 2024
Conic pseudo-elementYes, via overflow: hidden or a masktransform: rotate() (composited) or @property::before, plus ::after or a maskNeeds positioning and isolation; square must outsize the box; mask-composite since December 2023

Performance: what repaints and what composites

Animating --gb-angle changes a value the background depends on, so on every frame the browser recalculates style and repaints that element's background on the main thread. For one button that's a small area and comfortably within the frame budget — the same cost class as animating background-color, which our CSS animation performance guide covers in detail.

Rotating a pseudo-element is different. The gradient is painted once, the layer goes to the compositor, and each frame is just a rotation of an existing texture. It stays smooth even while JavaScript keeps the main thread busy.

Two practical rules follow. Keep animated borders few and small: a spinning border on one call to action is fine, but on every card in a grid of thirty it becomes thirty repaints per frame. And check, rather than guess: turn on Paint flashing in Chrome's Rendering panel. The @property demo flashes continuously; the transform demo shouldn't.

Accessibility: reduced motion and focus

A border that loops forever is exactly the kind of motion prefers-reduced-motion exists for. This page's stylesheet switches the animations off for anyone who has asked their system for less motion:

@media (prefers-reduced-motion: reduce) {
  .gb-clip,
  .gb-glow::before { animation: none; }
}

The angle falls back to its initial-value and the pseudo-element to its resting transform, so both demos become static gradient borders rather than disappearing. Turn on reduced motion in your operating system and reload to check.

Don't let the moving border be the focus indicator. It's already animating before the button receives focus, so its appearance doesn't change when focus arrives and a keyboard user gets no signal. Colour is the other risk: WCAG 1.4.11 asks for 3:1 contrast against adjacent colours, and a gradient is only as strong as its weakest stop. On this dark page all three stops pass, the violet narrowly at about 4.7:1. Put the same gradient on white and the cyan stop measures about 2.4:1. Our WCAG colour contrast guide explains how to measure it. The demo button therefore keeps its gradient and adds a solid near-white :focus-visible outline, offset so it sits on the page background, where it measures about 19:1.

Which one to use

Square corners or a transparent interior on a simple box: border-image. Rounded corners on a solid button or card: clipped backgrounds — the least code, with no extra element. A glow, a sweep or anything that animates continuously: the pseudo-element, rotated with transform so the compositor does the work. Whichever you pick, more ideas for interactive states live in our CSS hover effects library.

Frequently asked questions

Why doesn't border-radius work with border-image?

By design. A border image can be pushed outside the border box with border-image-outset, so browsers do not clip it to the rounded corner. For a rounded gradient border, paint the gradient as a background clipped to the border box under a solid fill clipped to the padding box, or use a pseudo-element.

Can you animate a CSS gradient?

Not directly. Gradients are images, and background-image animates discretely, so one gradient snaps to the next. Register a custom property with @property, use it inside the gradient for an angle or stop position, and animate that property instead. Alternatively, move background-position or rotate a pseudo-element with transform.

How do I make a gradient border with rounded corners?

Give the element a transparent border and two backgrounds: a solid colour clipped to padding-box on top and the gradient clipped to border-box underneath. The gradient shows only in the border ring, which follows border-radius. The inner colour must be opaque.

Is an animated gradient border bad for performance?

One small one is fine. Animating a @property angle repaints the element every frame on the main thread, so the cost grows with size and count. Rotating a pseudo-element with transform is composited and cheaper. Keep animated borders to a few small elements and honour prefers-reduced-motion.