Buttons✨ Premium

CTA Gradient Border

A 2 px ring mask-cut from an indigo → pink → amber gradient that slides in a 4 s loop around a dark face; on hover it speeds up to 1.5 s and an indigo halo appears — pure CSS.

CSSCTAGradient

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

Get started for free — SaaS landing page — CTA Gradient Border example 1

① Get started for free — SaaS landing page

WhenDark hero: headline, argument, one primary button and a secondary link.
WhyThe indigo → pink → amber trio is the signature of 2024-2026 “premium” CTAs; the sliding ring draws the eye to the hero's only button without filling the face with color, which keeps the text at 19.8:1.
Settings16 px text, padding: 18px 40px, 6s cycle for a calm hero, hover halo raised to 0 0 40px rgba(99,102,241,.35).
Upgrade to Pro — upgrade card — CTA Gradient Border example 2

② Upgrade to Pro — upgrade card

WhenOffer card in a pricing grid or a trial banner: listed benefits, one upgrade button.
WhyA single animated ring in the grid designates the plan to push; on a dark face identical to the other buttons, the outline does the ranking, not a solid color.
SettingsButton spanning the card width, padding: 13px, gradient reduced to two colors (#6366f1, #ec4899) to match the plan's accent.
Discover v2 — feature announcement card — CTA Gradient Border example 3

③ Discover v2 — feature announcement card

WhenWhat's-new panel in a dashboard or a modal: a “New” tag, a sentence, one button.
WhyThe multicolor ring says “new” without a blinking badge; it spins continuously but is only 2 px, which keeps it bearable in a working interface. The hover halo confirms the target.
SettingsCompact button (padding: 11px 20px, 13.5 px), border-radius: 10px (ring at 12px), 5s cycle, padding: 1.5px on the ::before for a thinner line.

How it works

The .btn-cta-gradient button is solid: background: #0a0a0f, color: #fff, padding: 18px 36px, position: relative; z-index: 1 and, through .demo-btn, overflow: hidden; border-radius: 12px. The ring is its ::before: inset: 0 (the button's exact size), padding: 2px, border-radius: 14px and a linear-gradient(135deg, #6366f1, #ec4899, #f59e0b) background stretched to 300% 300%.

The cut-out comes from mask: two layers, linear-gradient(#fff 0, #fff 0) content-box (a solid rectangle limited to the content box, i.e. everything but the 2 px of padding) and linear-gradient(#fff 0, #fff 0) (the whole box), composited with exclude: where both overlap, the mask is empty. Only the 2 px band between the two boxes remains. Measured in Chromium: colored pixels at 0 and 1 px from the edge, #0a0a0f face from 3 px on.

The motion is gradientMove: background-position goes from 0 0 to 100% 100% and back, 4s ease infinite. Since the image is three times the box, that trip scrolls the three colors along the ring — indigo at one corner, amber at the other, pink in between, then reversed. The keyframes declare two positions (0 0, 100% 100%) for a single layer: the browser ignores the second one.

On :hover, two things: box-shadow: 0 0 30px rgba(99,102,241,.3) on the button (no transition declared, so instant) and the same animation at 1.5s on the ::before. Changing the duration doesn't restart the animation: the browser keeps its currentTime. Measured: at 1.33 s into the 4 s cycle (33%), switching to 1.5 s places that same instant at 89% of the new cycle — the gradient jumps to another position on hover in, then again on hover out. See the FAQ.

Accessibility

  • Text contrast: white on #0a0a0f = 19.8:1, AAA — one of the few buttons in the catalog that passes untouched. The face is opaque: the page color changes nothing.
  • Ring contrast (WCAG 1.4.11, 3:1 threshold for a component's boundary): on the dark face, #6366f1 = 4.4:1, #ec4899 = 5.6:1, #f59e0b = 9.2:1 — the 2 px outline is perceptible at every point of the cycle, on dark and light page backgrounds alike.
  • prefers-reduced-motion not handled: a permanently sliding gradient. Add @media (prefers-reduced-motion: reduce) { .btn-cta-gradient::before { animation: none } } — the ring stays, frozen at position 0 0 (indigo → pink).
  • Dangerous fallback without mask: the code relies on unprefixed mask. On Chrome before 120 and Safari before 15.4, the mask is ignored and the ::before becomes a solid gradient rectangle painted above the text (it is positioned, hence painted after the content): the button has no visible label anymore. Duplicate the declarations as -webkit-mask / -webkit-mask-composite: xor, or wrap the ::before in @supports (mask-composite: exclude).
  • Keyboard and touch: nothing essential depends on hover — the ring spins for everyone. :hover only adds a halo and a speed-up; the native outline is preserved and not clipped by overflow: hidden. Target ≈ 54 px tall, above the 44 px minimum.

Browser compatibility

Requires the unprefixed mask shorthand with mask-composite: exclude, inset and a background-position animation. No JavaScript, zero dependencies.

Chrome 120+✓ Full
Firefox 53+✓ Full
Safari 15.4+✓ Full
Edge 120+✓ Full
Mobile iOS 15.4+✓ Full
Android Chrome 120+✓ Full

Without mask support (Chrome before 120, Safari before 15.4), the pseudo-element is no longer cut out: a solid gradient rectangle covers the whole button, label included. Add the -webkit-mask and -webkit-mask-composite: xor declarations, or gate the ::before behind @supports (mask-composite: exclude) to fall back to a dark button with no ring.

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 4s / :hover::before 1.5s) 4s / 1.5s Scroll speed at rest and on hover. A different value on hover causes a position jump (see FAQ); keep the same one for continuous motion.
linear-gradient(135deg, #6366f1, #ec4899, #f59e0b) (CSS — ::before) indigo → pink → amber Ring colors. Two colors are enough; the more stops, the faster the scroll looks at equal duration.
padding (CSS — ::before) 2px Ring thickness: it's the zone between the content box (excluded by the mask) and the edge. 1px = hairline; 3px = bold frame.
background-size (CSS — ::before) 300% 300% Gradient stretch. Larger = softer color transitions and slower scroll at equal duration; 200% shows more colors at once.
background (CSS — .btn-cta-gradient) #0a0a0f Face color. Same as the catalog's background; transparent lets the page show through, the ring alone draws the button.
box-shadow (CSS — :hover) 0 0 30px rgba(99,102,241,.3) Hover halo, instant (no transition declared). Add transition: box-shadow .3s to the button for a fade.
border-radius (CSS — .demo-btn 12px / ::before 14px) 12 / 14 px Rounding of the button and of the ring. Keep them equal: at 14 px the ring's arc curves in earlier than the button's and leaves a thin crescent of bare face in each corner.

FAQ

Because hover changes animation-duration (4 s → 1.5 s) on an animation that keeps its currentTime: an instant at 33% of the slow cycle lands at 89% of the fast one, and the gradient position changes at once — then again on the way back. To speed up without a jump, don't change the duration: keep 4s and stack a second animation on :hover::before (animation: gradientMove 4s ease infinite, gradientMove 1.5s ease infinite doesn't work on the same property), or settle for the halo as hover feedback — the simplest and cleanest choice.
The mask isn't supported (Chrome before 120, Safari before 15.4). The ::before is then no longer cut out: it's a button-sized gradient rectangle, positioned, hence painted above the text, which it fully covers. Two protections: duplicate the declarations with the prefix (-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: xor;), which covers Chrome 1–119 and Safari 4–15.3; and wrap the whole ::before rule in @supports (mask-composite: exclude) or (-webkit-mask-composite: xor) so a browser without masks shows only a dark, readable button with no ring.
Yes: .btn-cta-gradient { background: transparent }. The mask only concerns the ::before, the ring stays identical and the text sits directly on the page. Then check the white label's contrast against your actual background, and keep z-index: 1 — it isn't used here to stack negative layers, but it isolates the button and prevents a positioned neighbor from slipping between the ring and the face.