Shimmer Button
Buttons

Shimmer Button

A flagship CTA with a slanted highlight band that sweeps on an ease-then-rest cycle — token-driven for both themes, paused offscreen, gone under reduced motion.

Install

npx shadcn@latest add @paragon/shimmer-button

shimmer-button.tsx

"use client";

import * as React from "react";
import { useInView } from "motion/react";
import { cn } from "@/lib/utils";

/*
 * One static keyframe drives every instance; per-instance color/duration
 * arrive through custom properties, so the <style> dedupes to a single tag.
 * The band eases across (0 -> 60%) then rests — a shimmer that never pauses
 * reads as noise, one that breathes reads as intent.
 */
const shimmerStyles = `
[data-slot="shimmer-button"] {
  box-shadow: inset 0 1px 0 0 color-mix(in oklch, var(--color-primary-foreground) 16%, transparent);
}
[data-slot="shimmer-button"] > [data-shimmer-band] {
  background: linear-gradient(
    115deg,
    transparent 20%,
    color-mix(in oklch, var(--pg-shimmer-color) 38%, transparent) 50%,
    transparent 80%
  );
  animation: pg-shimmer-sweep var(--pg-shimmer-duration, 2.8s) infinite;
}
@keyframes pg-shimmer-sweep {
  0% { translate: -120% 0; animation-timing-function: cubic-bezier(0.77, 0, 0.175, 1); }
  60%, 100% { translate: 280% 0; }
}
@media (prefers-reduced-motion: reduce) {
  [data-slot="shimmer-button"] > [data-shimmer-band] {
    animation: none !important;
    opacity: 0;
  }
}
`;

export interface ShimmerButtonProps extends React.ComponentProps<"button"> {
  /** Highlight color of the traveling band. Defaults to the label token. */
  shimmerColor?: string;
  /** Seconds per sweep, including the rest between passes. */
  duration?: number;
  /** Freezes the shimmer; press physics stay. */
  static?: boolean;
}

/**
 * The flagship CTA. A slanted highlight band sweeps the surface on an
 * ease-then-rest cycle, riding tokens so it holds up in both themes. The
 * loop pauses offscreen, dies under reduced motion, and the band is a
 * decorative transform-only layer — the button underneath is the house
 * button: press scale, focus ring, enumerated transitions.
 */
export function ShimmerButton({
  shimmerColor,
  duration = 2.8,
  static: isStatic = false,
  className,
  style,
  children,
  ...props
}: ShimmerButtonProps) {
  const ref = React.useRef<HTMLButtonElement>(null);
  const inView = useInView(ref, { amount: 0.1 });

  return (
    <>
      <style href="paragon-shimmer-button" precedence="paragon">
        {shimmerStyles}
      </style>
      <button
        ref={ref}
        type="button"
        data-slot="shimmer-button"
        className={cn(
          "relative inline-flex h-10 items-center justify-center gap-2 overflow-hidden rounded-lg bg-primary px-6 text-sm font-medium whitespace-nowrap text-primary-foreground",
          "transition-[background-color,box-shadow,scale] duration-150 ease-out",
          "hover:bg-primary/90",
          "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          "disabled:pointer-events-none disabled:opacity-50",
          "active:not-disabled:scale-[0.97]",
          "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
          className,
        )}
        style={
          {
            "--pg-shimmer-color":
              shimmerColor ?? "var(--color-primary-foreground)",
            "--pg-shimmer-duration": `${duration}s`,
            ...style,
          } as React.CSSProperties
        }
        {...props}
      >
        <span
          aria-hidden
          data-shimmer-band=""
          className="pointer-events-none absolute inset-y-0 left-0 w-1/2"
          style={{
            animationPlayState: !inView || isStatic ? "paused" : undefined,
            opacity: isStatic ? 0 : undefined,
          }}
        />
        <span className="relative inline-flex items-center gap-2">
          {children}
        </span>
      </button>
    </>
  );
}