Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/gradient-button.jsonIt adds these packages for you:
clsxtailwind-mergeThe source lands in your components folder as
gradient-button.tsx. It's yours: change anything.
Usage
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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| animated | boolean | true | Sheen across the solid button; beam round the outline one. Never moves under reduced motion. |
| speed | number | 4 | Seconds between sheens, or for one lap of the beam. |
| --gradient-from / -via / -to | CSS variable | around --primary | Gradient colours. Set them in className, e.g. [--gradient-from:var(--color-pink-500)]. |
| ...props | ComponentProps<"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
- v1.0.0Latest
First release.