Rotating Border
An indigo → fuchsia conic-gradient rectangle spins under a dark face and shows only through a 1 px ring — one turn every 3 s, no hover, pure CSS.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Buttons has 27 effects, including 2 free. Explore the category →
3 usage examples



How it works
Three stacked layers. The .btn-rotate-border button is transparent (background: 0 0), with position: relative; overflow: hidden; border-radius: 12px inherited from .demo-btn and z-index: 1, which creates a stacking context — essential so that the negative-z-index pseudo-elements stay inside the button instead of falling behind the page.
::before (z-index: -2) is the gradient: inset: -3px, i.e. a rectangle overflowing 3 px on every side, filled with conic-gradient(#6366f1, #d946ef, #6366f1) — indigo at 0°, fuchsia at 180°, back to indigo, no visible seam. It is animated by rotateBorder: transform: rotate(360deg), 3s linear infinite. ::after (z-index: -1) is the face: inset: 1px; background: #0a0a0f; border-radius: 11px. Between the button's edge (clipped to 12 px by overflow: hidden) and that face, a 1 px ring remains where the gradient shows.
What spins is not the gradient but the rectangle carrying it, and that is where the code shows its limit. The demo button measures 190 × 54 px; its ::before is 196 × 60 px and pivots around its center. At 0° and 180° it covers the whole ring; as soon as it turns, its short side (60 px) no longer spans the button's width and the ring is no longer fed on the flanks. Measured in Chromium at 45°, 90° and 135°: the pixels at the left edge and the corner are the page color, only two short arcs remain visible (top-left and bottom-right), sliding along the edge.
The real effect is therefore a ring that lights up fully twice per turn (every 1.5 s) and shrinks in between to two moving dashes — closer to a “scanner” than to a spinning gradient. There is no :hover or :active rule: the animation runs from load, permanently. The FAQ gives the two ways to make the ring continuous.
Accessibility
- prefers-reduced-motion not handled: a permanent rotation in the field of view. Add
@media (prefers-reduced-motion: reduce) { .btn-rotate-border::before { animation: none } }— what remains is a fixed, complete gradient ring (at 0° the rectangle covers everything), perfectly readable. - Text contrast: white on the
#0a0a0fface = 19.8:1, AAA. The face is opaque: the page color behind it changes nothing about legibility. - Ring contrast (WCAG 1.4.11, UI components, 3:1 threshold):
#6366f1on#0a0a0f= 4.4:1 and#d946ef= 5.7:1 — the outline is perceptible, but it is only 1 px wide and vanishes on the flanks for most of the cycle. Don't rely on it to delimit the button: a dark face on a dark background (#0a0a0fon#0a0a0f) has no edge of its own. On an identical page background, add::after { background: #12121a }or a fixed 1 px border. - Screen readers: both pseudo-elements have
content: ""— nothing is announced, the accessible name is the label. The button remains a native<button>, focusable and keyboard-operable; the animation has no dependency on hover, it is identical for everyone. - Focus: the code doesn't touch
outline, the native focus ring stays visible and is not clipped byoverflow: hidden(outlines are drawn outside the box). Target ≈ 54 px tall (18 px padding + text), above the 44 px minimum.
Browser compatibility
Requires the inset shorthand, conic-gradient() and a CSS transform animation on a pseudo-element. No JavaScript, zero dependencies.
Without inset (Safari before 14.1, Firefox before 66), the pseudo-elements have neither size nor position: the button is plain white text with no background or outline. Replace inset with explicit top/right/bottom/left for those browsers. Without conic-gradient (Firefox before 83), the ::before has no background: invisible ring, intact face.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
| animation-duration (CSS — ::before, rotateBorder) | 3s | Length of one turn. The complete ring reappears twice per turn: 2s = one flash per second; 6s = slow sweep. |
| conic-gradient(#6366f1, #d946ef, #6366f1) (CSS — ::before) | indigo → fuchsia → indigo | Ring colors. Repeat the first color last to avoid a hard seam at 0°. Add stops (#6366f1, #22d3ee, #d946ef, #6366f1) for a multicolor ring. |
| inset (CSS — ::after) | 1px | Thickness of the visible ring: it's the gap between the button's edge and the face. 2px doubles the ring; then adjust the face's border-radius (12 − inset). |
| background (CSS — ::after) | #0a0a0f | Face color. Same as the catalog's background; on another page, match your surface or lighten it (#12121a) so the button has a clean edge. |
| inset (CSS — ::before) | -3px | Overflow of the spinning rectangle. No effect on thickness (the button clips at its edge); it's the shape of the ::before that decides ring coverage — see the FAQ for a continuous ring. |
| border-radius (CSS — .demo-btn 12px / ::before 14px / ::after 11px) | 12 / 14 / 11 px | Rounding of the three layers. Keep face = button − face inset. 999px everywhere gives a pill; the ring is covered even less of the time there. |
| animation-play-state (CSS — to add) | running | Set paused at rest and running under :hover for a still ring that starts spinning on hover, without restarting from zero. |
FAQ
::before is the button's size + 3 px; once rotated 90°, its short side (≈ 60 px) no longer covers the 190 px width, and the ring has nothing left to show on the flanks. Two fixes. Either a square pseudo-element larger than the diagonal: .btn-rotate-border::before { inset: auto; left: 50%; top: 50%; width: 200%; aspect-ratio: 1; translate: -50% -50% } — the ring is then fed at every angle. Or stop rotating the box and rotate the gradient's angle instead: @property --a { syntax: '<angle>'; inherits: false; initial-value: 0deg }, conic-gradient(from var(--a), …) and @keyframes { to { --a: 360deg } } — cleaner, but Firefox 128+ and Safari 16.4+ only.animation declaration and add .btn-rotate-border::before { animation-play-state: paused } then .btn-rotate-border:hover::before { animation-play-state: running }. The ring stays complete at rest (at 0° the rectangle covers everything) and resumes from where it stopped. If you'd rather it always restart from 0°, declare animation only under :hover::before.z-index values (−2 and −1). Without a stacking context on the button, they would sit behind the page background and become invisible. z-index: 1 with position: relative creates that context: the negative layers paint above the button's background but below its text. Remove z-index and both ring and face disappear, leaving only the white text.