Buttons✨ Premium

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.

CSSAnimationBorder

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

Try for free — AI or SaaS product landing page — Rotating Border example 1

① Try for free — AI or SaaS product landing page

WhenDark hero: headline, subtitle, one primary button isolated in the center.
WhyIndigo → fuchsia is the palette of 2024-2026 AI products, and the animated ring draws the eye to the page's only button without filling it with color. At this size, the two arcs travelling along the edge read as a deliberate sweep.
Settings16 px text, padding: 18px 40px, 4s rotation for a calmer sweep, #0d0d16 face to stand out from the background.
Choose Pro — recommended plan in a pricing grid — Rotating Border example 2

② Choose Pro — recommended plan in a pricing grid

WhenThree price cards; the middle plan carries the animated button, the others a neutral one.
WhyA single moving element in the grid designates the recommended plan without a label. The 1 px ring stays discreet on a dense card; save the animation for the plan you're pushing.
SettingsButton spanning the card width, padding: 13px, gradient aligned with the plan's accent, animation-duration: 5s.
Generate — input bar of a generation tool — Rotating Border example 3

③ Generate — input bar of a generation tool

WhenPrompt field with suggestions and a submit button on the right.
WhyThe animated button flags the interface's “magic” action while staying as dark as the field; the permanent motion says the tool is ready. On a short, nearly square button, the spinning rectangle covers the ring far longer — this is where the effect is at its best.
SettingsCompact button (padding: 12px 18px), border-radius: 10px across the three layers (12px / 9px for the pseudo-elements), 2.5s rotation.

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 #0a0a0f face = 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): #6366f1 on #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 (#0a0a0f on #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 by overflow: 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.

Chrome 87+✓ Full
Firefox 83+✓ Full
Safari 14.1+✓ Full
Edge 87+✓ Full
Mobile iOS 14.5+✓ Full
Android Chrome✓ Full

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):

index.html — structure
🔒 Unlock the full code — from €2.99 the first month

Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
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

Because a rectangle spins, not the gradient. The ::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.
Keep the 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.
The pseudo-elements have negative 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.