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.
border-radius: 14px is set but ignored@property angle, solid focus ringtransform, static glowAll 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
| Technique | Rounded corners | Animatable how | Extra element | Support / caveats |
|---|---|---|---|---|
border-image | No — border-radius is ignored | @property angle inside the gradient (repaints) | None | Widely available since 2015; interior stays transparent; needs a border style and the 1 slice |
| Clipped backgrounds | Yes | @property angle (repaints) | None | Inner fill must be an opaque colour that matches the design; @property is Baseline 2024 |
| Conic pseudo-element | Yes, via overflow: hidden or a mask | transform: rotate() (composited) or @property | ::before, plus ::after or a mask | Needs 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.