Skip to content
Uxelith
Sign in
Freev1.0.0Actions

Gradient Button

A call-to-action button with a tasteful gradient: neighbouring hues, a light that follows the pointer and a sheen that passes now and then. The outline style is quiet until a beam of light runs round its border.

pnpm dlx shadcn add …/r/gradient-button.json
Licence
MIT
Packages
2
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/gradient-button.json
  2. It adds these packages for you:

    clsxtailwind-merge
  3. The source lands in your components folder as gradient-button.tsx. It's yours: change anything.

Usage

example.tsx
import { GradientButton } from "@/components/gradient-button"

export function Hero() {
  return (
    <div className="flex gap-3">
      <GradientButton size="lg">Start building</GradientButton>
      <GradientButton size="lg" variant="outline">Watch the demo</GradientButton>
    </div>
  )
}

Source

One file, 133 lines, nothing hidden. Read it before you install it.

gradient-button.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

export interface GradientButtonProps extends React.ComponentProps<"button"> {
  /** `solid` fills the button with the gradient; `outline` draws it as a border with a beam of light running round it. */
  variant?: "solid" | "outline";
  size?: "sm" | "md" | "lg";
  /** A sheen sweeps across the solid button now and then; the beam circles the outline one. Always still for people who prefer reduced motion. */
  animated?: boolean;
  /** Seconds between sheens, or for one lap of the beam. */
  speed?: number;
}

const sizes = {
  sm: "h-9 gap-1.5 px-4 text-sm",
  md: "h-11 gap-2 px-5 text-sm",
  lg: "h-13 gap-2.5 px-7 text-base",
} as const;

// Keeps only the 1px edge of the element (content box cut out of the border box).
const RING_MASK: React.CSSProperties = {
  WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
  WebkitMaskComposite: "xor",
  mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
};

const GRADIENT = "linear-gradient(135deg,var(--gradient-from),var(--gradient-via) 55%,var(--gradient-to))";

// A bright head with a long tail. It turns with the square behind the border, so it runs round the edge.
const BEAM =
  "conic-gradient(transparent 0deg 210deg,var(--gradient-from) 290deg,var(--gradient-via) 345deg,color-mix(in oklab,var(--gradient-via),white 55%) 357deg,transparent 360deg)";

/**
 * A call-to-action button with a gradient that stays tasteful: neighbouring
 * hues, a light that follows the pointer, a lit top edge and a sheen that
 * passes now and then. The outline variant is quiet until a beam of light
 * runs round its border.
 *
 * Colours are CSS variables — `--gradient-from`, `--gradient-via`,
 * `--gradient-to`, `--gradient-foreground` — defaulting to hues next to your
 * theme's `--primary`. Override them with `className`
 * (`[--gradient-from:var(--color-pink-500)]`) or `style`.
 *
 * It is a real `<button>`: type, disabled, focus and keyboard behaviour are
 * the browser's own. Pure CSS (the sheen needs the `ux-sheen` keyframes,
 * which the registry adds), so it costs no animation library.
 */
export function GradientButton({
  variant = "solid",
  size = "md",
  animated = true,
  speed = 4,
  className,
  style,
  type = "button",
  onPointerMove,
  children,
  ...props
}: GradientButtonProps) {
  return (
    <button
      type={type}
      data-variant={variant}
      onPointerMove={(event) => {
        onPointerMove?.(event);
        // The highlight follows the pointer; written as CSS variables so React doesn't re-render.
        const rect = event.currentTarget.getBoundingClientRect();
        event.currentTarget.style.setProperty("--pointer-x", `${event.clientX - rect.left}px`);
        event.currentTarget.style.setProperty("--pointer-y", `${event.clientY - rect.top}px`);
      }}
      className={cn(
        // Theme-derived defaults; any of these can be overridden from className or style.
        "[--gradient-from:var(--primary)] [--gradient-via:oklch(from_var(--primary)_calc(l+0.06)_c_calc(h+22))] [--gradient-to:oklch(from_var(--primary)_calc(l-0.04)_c_calc(h-18))] [--gradient-foreground:var(--primary-foreground)]",
        "group/gradient relative isolate inline-flex shrink-0 cursor-pointer select-none items-center justify-center overflow-hidden whitespace-nowrap rounded-lg font-medium tracking-tight outline-none",
        "[--pointer-x:50%] [--pointer-y:0%]",
        "transition-[scale,box-shadow,filter] duration-200 ease-out active:scale-[0.97] motion-reduce:active:scale-100",
        "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 disabled:saturate-50",
        variant === "solid"
          ? [
              "text-[var(--gradient-foreground)]",
              // A lit top edge, a darker bottom edge and a coloured glow that grows on hover.
              "shadow-[inset_0_1px_0_rgb(255_255_255/0.3),inset_0_-1px_0_rgb(0_0_0/0.18),0_1px_2px_rgb(0_0_0/0.2),0_8px_24px_-12px_var(--gradient-via)]",
              "hover:shadow-[inset_0_1px_0_rgb(255_255_255/0.35),inset_0_-1px_0_rgb(0_0_0/0.18),0_1px_2px_rgb(0_0_0/0.2),0_12px_32px_-12px_var(--gradient-via)]",
            ]
          : [
              "bg-background text-foreground",
              "shadow-[0_1px_2px_rgb(0_0_0/0.12)] hover:shadow-[0_8px_26px_-12px_var(--gradient-via)]",
            ],
        sizes[size],
        className,
      )}
      style={{ "--gradient-speed": `${speed}s`, ...(variant === "solid" && { backgroundImage: GRADIENT }), ...style } as React.CSSProperties}
      {...props}
    >
      {variant === "outline" && (
        <span aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 rounded-[inherit]">
          {/* Where the beam passes, it lights the inside of the button a little. */}
          <span className={cn("absolute left-1/2 top-1/2 aspect-square w-[max(100%,12rem)] -translate-x-1/2 -translate-y-1/2 animate-spin opacity-15 blur-md [animation-duration:var(--gradient-speed)] motion-reduce:hidden", !animated && "hidden")} style={{ backgroundImage: BEAM }} />
          {/* The border: a faint gradient ring, with the beam running over it. Masked to the edge. */}
          <span className="absolute inset-0 rounded-[inherit] p-[1.5px]" style={RING_MASK}>
            <span className="absolute inset-0 opacity-40 transition-opacity duration-200 group-hover/gradient:opacity-70" style={{ backgroundImage: GRADIENT }} />
            <span className={cn("absolute left-1/2 top-1/2 aspect-square w-[max(100%,12rem)] -translate-x-1/2 -translate-y-1/2 animate-spin [animation-duration:var(--gradient-speed)] motion-reduce:hidden", !animated && "hidden")} style={{ backgroundImage: BEAM }} />
          </span>
        </span>
      )}
      {/* Light that follows the pointer. */}
      <span
        aria-hidden="true"
        className={cn(
          "pointer-events-none absolute inset-0 -z-10 rounded-[inherit] opacity-0 transition-opacity duration-200 group-hover/gradient:opacity-100",
          variant === "solid"
            ? "bg-[radial-gradient(7rem_circle_at_var(--pointer-x)_var(--pointer-y),rgb(255_255_255/0.28),transparent_70%)]"
            : "bg-[radial-gradient(7rem_circle_at_var(--pointer-x)_var(--pointer-y),color-mix(in_oklab,var(--gradient-via)_16%,transparent),transparent_70%)]",
        )}
      />
      {variant === "solid" && (
        <span
          aria-hidden="true"
          className={cn(
            "pointer-events-none absolute inset-y-0 left-0 -z-10 w-full bg-[linear-gradient(105deg,transparent_30%,rgb(255_255_255/0.32)_50%,transparent_70%)]",
            // One sweep, then a rest: the whole cycle lasts 1.25× the gap between sheens. The keyframes place it, so it hides when still.
            animated ? "animate-[ux-sheen_calc(var(--gradient-speed)*1.25)_cubic-bezier(0.65,0,0.35,1)_infinite] motion-reduce:hidden" : "hidden",
          )}
        />
      )}
      {children}
    </button>
  );
}

Props

PropTypeDefaultDescription
variant"solid" | "outline""solid"Fill with the gradient, or a quiet outline with a beam of light running round the border.
size"sm" | "md" | "lg""md"Height, padding and text size.
animatedbooleantrueSheen across the solid button; beam round the outline one. Never moves under reduced motion.
speednumber4Seconds between sheens, or for one lap of the beam.
--gradient-from / -via / -toCSS variablearound --primaryGradient colours. Set them in className, e.g. [--gradient-from:var(--color-pink-500)].
...propsComponentProps<"button">—Every native button attribute, including ref, type and disabled.

Accessibility

  • A native <button>, so Enter, Space, focus and disabled work without extra code.
  • A visible focus ring that sits outside the gradient.
  • The sheen and the beam stop entirely with prefers-reduced-motion; the pointer light is hover-only and never needed to read the button.
  • Default text colour is your theme's --primary-foreground, chosen for contrast against --primary.

Changelog

  1. v1.0.0Latest

    First release.

More in Actions

All 4 in Actions
  • ActionsFree

    Copy Button

    Copies text to the clipboard: the icon morphs into a tick (or a cross that shakes if it failed) and a visible label reads "Copied". Announces the result and falls back where the Clipboard API is missing.

  • ActionsFree

    Magnetic Button

    A button with a gentle magnetic field: it leans toward the pointer as you come near, a few pixels at most, its label following a little further for depth, and settles back without a wobble.

  • ActionsFree

    Tooltip

    A small hint that springs out of the side facing its element on hover or keyboard focus. Group a toolbar's tooltips and neighbours open at once while one bubble glides between them. Dismissable and hoverable (WCAG 1.4.13).