Swish UI
All componentsButtons and controls Free

Shimmer button

A band of light sweeps across the button once when it appears and again each time you hover it.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const inView = useInView(root);
  const reduced = usePrefersReducedMotion();
  const [sweeping, setSweeping] = useState(false);
  const played = useRef(false);

  // Sweep once the first time the button scrolls into view.
  useEffect(() => {
    if (!inView || reduced || played.current) return;
    const timer = setTimeout(() => {
      played.current = true;
      setSweeping(true);
    }, 400);
    return () => clearTimeout(timer);
  }, [inView, reduced]);

  const replay = () => {
    if (!reduced) setSweeping(true);
  };

  return (
    <div ref={root} className={styles.root}>
      <div className={styles.stack}>
        <button
          type="button"
          className={`${styles.button} ${sweeping ? styles.sweeping : ""}`}
          onPointerEnter={replay}
          onFocus={replay}
        >
          <span className={styles.shine} onAnimationEnd={() => setSweeping(false)} aria-hidden="true" />
          <span className={styles.label}>Start free trial</span>
        </button>
        <p className={styles.note}>14 days free, no card needed</p>
      </div>
    </div>
  );
}

demo.module.css

.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.stack { display: grid; justify-items: center; gap: 12px; }
.button {
  position: relative; overflow: hidden; isolation: isolate;
  min-height: 54px; padding: 0 34px; border-radius: 999px; border: 0;
  background: var(--ink); color: var(--white); font-size: 16px; font-weight: 600; cursor: pointer;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--white) 14%, transparent), 0 10px 24px -16px var(--ink);
  transition: transform .18s var(--out);
}
.button:active { transform: scale(.97); }
.label { position: relative; z-index: 1; }
.shine {
  position: absolute; inset: 0; pointer-events: none; transform: translateX(-100%);
  background: linear-gradient(110deg, transparent 30%, color-mix(in srgb, var(--accent-soft) 20%, transparent) 42%, color-mix(in srgb, var(--white) 55%, transparent) 50%, color-mix(in srgb, var(--accent-soft) 20%, transparent) 58%, transparent 70%);
}
.sweeping .shine { animation: sweep 1.1s ease-in-out; }
@keyframes sweep { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
.note { font-size: 13px; color: var(--muted); }
@media (prefers-reduced-motion: reduce) { .shine { display: none; } }

Use it for

  • The single most important call to action on a page
  • Upgrade or trial buttons that need a little extra pull

Avoid

  • More than one per screen, since several shimmers compete
  • Destructive or serious actions, where sparkle feels wrong

How it works

An absolutely positioned span holds a diagonal linear gradient that is transparent at both ends and bright in the middle. A class runs a keyframe that translates it from -100% to 100% across the button, which clips it with overflow hidden. Pointerenter and focus add the class, animationend removes it, so every hover replays the sweep once. The first sweep waits for useInView so it plays when the user can see it.

Accessibility

The shine is aria-hidden and purely decorative. Reduced motion removes it and leaves a plain button. Keyboard focus triggers the same sweep as hover.

Found on