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.
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
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 position0 0(indigo → pink). - Dangerous fallback without
mask: the code relies on unprefixedmask. On Chrome before 120 and Safari before 15.4, the mask is ignored and the::beforebecomes 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::beforein@supports (mask-composite: exclude). - Keyboard and touch: nothing essential depends on hover — the ring spins for everyone.
:hoveronly adds a halo and a speed-up; the nativeoutlineis preserved and not clipped byoverflow: 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.
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):
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 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
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.::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..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.