Animated Gradient Border Button

Three button variants share a 1px conic-gradient ring that orbits forever via a GSAP-tweened --angle; hovering triples the orbit speed, sweeps a highlight across the face and rolls the label up to a second copy. Active presses scale to 0.97.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1248 words of exact values — rewritten live as you tune the Playground.

prompt.md · Animated Gradient Border Button
### EFFECT: Animated Gradient Border Button
**What it does (visual):** Three pill/rounded buttons sit in a row on a dark page: a filled "Start a project" (light face, dark text), a transparent "View the reel" ghost, and a 10px-radius "Book a call" with a small arrow. Every button is wrapped by a 1.5px ring in which a bright `#d4ff3a` segment (about a quarter of the circumference, fading into a faint grey remainder) orbits clockwise continuously, one revolution every 3 seconds — the three rings are offset by a third of a turn so they never line up. On hover the orbit smoothly accelerates to 3× speed (one revolution every `3 / 3` s; the speed-up itself is eased over 0.4s), a soft diagonal highlight sweeps across the button face from right to left in 0.8s (sweep enabled: true), and the label rolls upward out of view while an identical copy (in the accent colour on the dark variants, same colour on the filled one) rolls up into its place in 0.5s. Leaving reverses the roll and eases the ring back down to the 3s orbit over 0.8s. Pressing scales the whole button to 0.97 for the duration of the press. On touch devices a tap performs the burst (ring speed-up, roll there and back, sweep) instead.

**Tech stack:**
- GSAP 3.13+ core only (no plugins).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` — npm: `import gsap from "gsap";`
- Modern CSS: `conic-gradient`, `mask-composite: exclude` (with `-webkit-mask-composite: xor`), `@property`, `color-mix()`.
- Fonts: a sans at 500 weight for labels (demo: Inter Tight), mono for captions.

**HTML structure:**
```html
<section class="gbb">
  <p class="gbb__eyebrow">Button · Gradient ring</p>
  <div class="gbb__row">
    <button class="gbb__btn gbb__btn--primary" type="button">
      <span class="gbb__text"><span class="gbb__roll">
        <span class="gbb__copy">Start a project</span>
        <span class="gbb__copy gbb__copy--alt" aria-hidden="true">Start a project</span>
      </span></span>
    </button>
    <button class="gbb__btn gbb__btn--ghost" type="button"> …same inner structure, "View the reel"… </button>
    <button class="gbb__btn gbb__btn--icon" type="button">
      …same inner structure, "Book a call"…
      <svg class="gbb__arrow" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2 8h11M9 3.5 13.5 8 9 12.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
    </button>
  </div>
</section>
```

**CSS requirements:**
- Register the variables: `@property --angle { syntax: '<angle>'; inherits: false; initial-value: 0deg; }` and `@property --sweep { syntax: '<percentage>'; inherits: false; initial-value: 110%; }`.
- `.gbb__btn`: `--angle: 0deg; --sweep: 110%; --ring: 1.5px; --ring-a: var(--accent, #d4ff3a); --ring-b: color-mix(in srgb, var(--text) 22%, transparent); position: relative; isolation: isolate; display: inline-flex; align-items: center; gap: .6em; padding: .95em 1.6em; border-radius: 999px; background: var(--surface, #121212); color: var(--text); font-weight: 500; font-size: clamp(.9rem, 1.05vw, 1rem); line-height: 1; letter-spacing: -0.01em; transition: transform .15s ease, color .3s ease;`
- `.gbb__btn:active { transform: scale(.97); }` — `.gbb__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }`
- Ring — `.gbb__btn::before`: `content: ''; position: absolute; inset: 0; padding: var(--ring); border-radius: inherit; z-index: -1; pointer-events: none; background: conic-gradient(from var(--angle), var(--ring-b) 0deg, var(--ring-b) 200deg, var(--ring-a) 290deg, var(--ring-b) 360deg); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude;` — the two masks cancel everywhere except the 1.5px padding frame, so only the ring is painted and the face stays solid.
- Shimmer — `.gbb__btn::after`: `content: ''; position: absolute; inset: var(--ring); border-radius: inherit; z-index: -1; pointer-events: none; background-image: linear-gradient(115deg, transparent 38%, color-mix(in srgb, var(--text) 10%, transparent) 50%, transparent 62%); background-size: 260% 100%; background-position: var(--sweep) 0; background-repeat: no-repeat;`
- Rolling label — `.gbb__text { display: block; overflow: hidden; height: 1em; line-height: 1 }`, `.gbb__copy { display: block; height: 1em; line-height: 1; white-space: nowrap }`, `.gbb__copy--alt { color: var(--accent) }`.
- Variants: `--primary { background: var(--text); color: var(--bg); --ring-b: color-mix(in srgb, var(--text) 40%, transparent) }` with its alt copy in `var(--bg)` and a shimmer built from `var(--bg)` at 22%; `--ghost { background: transparent; --ring-b: color-mix(in srgb, var(--text) 18%, transparent) }`; `--icon { border-radius: 10px; --ring-a: var(--text); --ring-b: color-mix(in srgb, var(--accent) 35%, transparent) }` (inverted ring: light segment on a faint accent track).
- Under 768px the row becomes a column of full-width buttons (`width: min(100%, 320px)`).

**Animation spec (exact values):**
1. Ring orbit (one per button, created at load): `const spin = gsap.fromTo(btn, { '--angle': '0deg' }, { '--angle': '360deg', duration: 3, ease: 'none', repeat: -1 })` then `spin.progress(i / 3)` to desync the three rings. Store it on the element (`btn._spin`).
2. Hover enter (pointer devices only):
   - speed-up: `gsap.to(btn._spin, { timeScale: 3, duration: 0.4, ease: 'power2.out', overwrite: true })` → `3 / 3` s per orbit.
   - roll: `gsap.to(btn.querySelectorAll('.gbb__copy'), { yPercent: -100, duration: 0.5, ease: 'power3.inOut', overwrite: true })`.
   - shimmer (only if true): `gsap.fromTo(btn, { '--sweep': '110%' }, { '--sweep': '-60%', duration: 0.8, ease: 'power2.out', overwrite: 'auto' })` (the `::after` reads `--sweep` as its `background-position-x`).
3. Hover leave: `gsap.to(btn._spin, { timeScale: 1, duration: 0.8, ease: 'power2.inOut', overwrite: true })` and `gsap.to(copies, { yPercent: 0, duration: 0.5, ease: 'power3.inOut', overwrite: true })`. The shimmer is not reversed — it simply finishes.
4. Active: CSS `:active { transform: scale(.97) }` with `transition: transform .15s ease` (no GSAP, so it never fights the tweens).
5. Touch tap: timeline `to(spin, { timeScale: 3, duration: 0.3, ease: 'power2.out' })` then `to(spin, { timeScale: 1, duration: 1, ease: 'power2.inOut' }, 0.6)`; label roll `yPercent → -100` (0.5s) then back to `0` after a `+=0.6` hold; one shimmer sweep (if enabled).

**Interaction:** Hover → ring ×3 speed, highlight sweep (when true), label roll (0.5s). Leave → ring eases back (0.8s), label rolls back. Press → scale .97. Keyboard focus shows a 2px accent outline offset 4px. Tap on touch → burst sequence above.

**Responsive behavior:** `gsap.matchMedia()` with two contexts: `(hover: hover) and (pointer: fine)` attaches `pointerenter`/`pointerleave`; `(hover: none)` attaches the `click` burst instead. Under 768px the CSS stacks the buttons full-width; type size is fluid via `clamp()`.

**Accessibility:** The duplicate label copy is `aria-hidden="true"` so screen readers hear each label once. Under `prefers-reduced-motion: reduce` do not create the orbit tweens or hover listeners: set `--angle: 300deg` so the accent segment rests at the top-right as a static accent, and change hover to a simple colour change (`color: var(--accent)`). Focus state is always visible; the ring is decorative (`pointer-events: none` pseudo-elements).

**Performance rules:** The ring repaints the pseudo-element each frame (a 1.5px masked gradient — cheap), but three infinite tweens must be paused when invisible: listen for the host's `ml:pause`/`ml:resume` (or `visibilitychange`) and `pause()`/`resume()` every spin. Labels and press use `transform` only. `isolation: isolate` keeps the negative `z-index` pseudo-elements inside the button's stacking context. Everything is inside `gsap.context()` and reverted on unmount.

**Customization knobs:**
- `SPIN_DURATION` (3s) and `HOVER_TIMESCALE` (3 → `3 / 3` s on hover).
- Segment length/colour: the conic stops `200deg → 290deg → 360deg` and `--ring-a` (#d4ff3a) / `--ring-b`.
- `--ring` thickness (1.5px; 1.5px reads well on large CTAs, 1px on compact ones).
- `ROLL_DURATION` (0.5s, `power3.inOut`), `SWEEP_DURATION` (0.8s), shimmer on/off (true) and the highlight opacity (10% / 22%).
- `:active` scale (.97).

**Acceptance criteria:**
- [ ] Only a 1.5px ring shows the gradient; the button face is perfectly solid (no gradient bleed, no second element).
- [ ] At rest each ring completes one revolution in 3s with linear motion; the three rings are offset.
- [ ] Hover reaches 3× speed smoothly (no snap), the highlight sweeps right-to-left in 0.8s (when enabled), and the label rolls to its second copy in 0.5s; leaving reverses the roll and the speed.
- [ ] Pressing scales to .97; keyboard focus is visible.
- [ ] Reduced motion: static ring at 300deg, no roll, no sweep; rings pause when the tab is hidden.
- [ ] No console errors; labels never show both copies at once.

Source

Full source code

<section class="gbb">
  <p class="gbb__eyebrow">Button · Gradient ring</p>
  <div class="gbb__row">
    <button class="gbb__btn gbb__btn--primary" type="button">
      <span class="gbb__text"><span class="gbb__roll"><span class="gbb__copy">Start a project</span><span class="gbb__copy gbb__copy--alt" aria-hidden="true">Start a project</span></span></span>
    </button>
    <button class="gbb__btn gbb__btn--ghost" type="button">
      <span class="gbb__text"><span class="gbb__roll"><span class="gbb__copy">View the reel</span><span class="gbb__copy gbb__copy--alt" aria-hidden="true">View the reel</span></span></span>
    </button>
    <button class="gbb__btn gbb__btn--icon" type="button">
      <span class="gbb__text"><span class="gbb__roll"><span class="gbb__copy">Book a call</span><span class="gbb__copy gbb__copy--alt" aria-hidden="true">Book a call</span></span></span>
      <svg class="gbb__arrow" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M2 8h11M9 3.5 13.5 8 9 12.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
    </button>
  </div>
  <p class="gbb__caption">Hover: the ring speeds up ×3, a highlight sweeps, the label rolls. Press: scale .97.</p>
</section>