Gradient Text
Text filled with a slowly drifting rainbow. It pauses while off screen and holds still when reduced motion is on.
Upgrade to Pro
"use client";
import { useState } from "react";
import { GradientText } from "@/components/motion/gradient-text";
import { Switch } from "@/components/motion/switch";
export function GradientTextPreview() {
const [active, setActive] = useState(true);
return (
<div className="flex flex-col items-center gap-8 text-center">
<p className="text-4xl font-semibold tracking-tight text-foreground">
Upgrade to{" "}
<GradientText active={active} className="-mb-[0.15em] inline-block pb-[0.15em]">
Pro
</GradientText>
</p>
<label
htmlFor="gradient-text-active"
className="flex cursor-pointer items-center gap-3 text-sm text-muted-foreground"
>
Highlight
<Switch id="gradient-text-active" size="sm" checked={active} onCheckedChange={setActive} />
</label>
</div>
);
}
"use client";
// easeui.dev/components/motion/gradient-text
import { type ComponentProps, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
/** A full color wheel. Painted three times larger than the text so only part of it shows at once. */
const SPECTRUM =
"conic-gradient(from 0deg, hsl(0 90% 60%), hsl(60 90% 55%), hsl(120 85% 50%), hsl(180 85% 50%), hsl(240 90% 65%), hsl(300 85% 60%), hsl(360 90% 60%))";
/** The visible window circles around the oversized gradient, so the colors drift through the text. */
const DRIFT: Keyframe[] = [
{ backgroundPosition: "0% 50%" },
{ backgroundPosition: "50% 100%" },
{ backgroundPosition: "100% 50%" },
{ backgroundPosition: "50% 0%" },
{ backgroundPosition: "0% 50%" },
];
export interface GradientTextProps extends Omit<ComponentProps<"span">, "ref"> {
/** Fills the text with the drifting spectrum. When false the text is muted. Default true. */
active?: boolean;
/** Seconds for one full loop. Default 5.5. */
duration?: number;
}
/**
* Text filled with a slowly drifting rainbow. The drift runs through the Web
* Animations API, so there is no global CSS to install. It pauses while off
* screen, and with reduced motion the gradient stays still.
*/
export function GradientText({
active = true,
duration = 5.5,
className,
style,
children,
...props
}: GradientTextProps) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const element = ref.current;
if (!active || !element || typeof element.animate !== "function") return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const animation = element.animate(DRIFT, {
duration: duration * 1000,
iterations: Number.POSITIVE_INFINITY,
easing: "linear",
});
let onScreen = true;
const sync = () => {
if (reducedMotion.matches || !onScreen) animation.pause();
else animation.play();
};
// Nothing to animate while the text is scrolled out of view.
const observer = new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting;
sync();
});
observer.observe(element);
reducedMotion.addEventListener("change", sync);
sync();
return () => {
observer.disconnect();
reducedMotion.removeEventListener("change", sync);
animation.cancel();
};
}, [active, duration]);
return (
<span
ref={ref}
data-active={active || undefined}
className={cn(
active
? "bg-clip-text font-semibold text-transparent [-webkit-background-clip:text]"
: "text-muted-foreground",
className,
)}
style={active ? { backgroundImage: SPECTRUM, backgroundSize: "300% 300%", ...style } : style}
{...props}
>
{children}
</span>
);
}
Installation
$ bunx --bun shadcn add @easeui/gradient-text
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx tailwind-merge - 3
Add the source files
components/motion/gradient-text.tsx "use client"; // easeui.dev/components/motion/gradient-text import { type ComponentProps, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; /** A full color wheel. Painted three times larger than the text so only part of it shows at once. */ const SPECTRUM = "conic-gradient(from 0deg, hsl(0 90% 60%), hsl(60 90% 55%), hsl(120 85% 50%), hsl(180 85% 50%), hsl(240 90% 65%), hsl(300 85% 60%), hsl(360 90% 60%))"; /** The visible window circles around the oversized gradient, so the colors drift through the text. */ const DRIFT: Keyframe[] = [ { backgroundPosition: "0% 50%" }, { backgroundPosition: "50% 100%" }, { backgroundPosition: "100% 50%" }, { backgroundPosition: "50% 0%" }, { backgroundPosition: "0% 50%" }, ]; export interface GradientTextProps extends Omit<ComponentProps<"span">, "ref"> { /** Fills the text with the drifting spectrum. When false the text is muted. Default true. */ active?: boolean; /** Seconds for one full loop. Default 5.5. */ duration?: number; } /** * Text filled with a slowly drifting rainbow. The drift runs through the Web * Animations API, so there is no global CSS to install. It pauses while off * screen, and with reduced motion the gradient stays still. */ export function GradientText({ active = true, duration = 5.5, className, style, children, ...props }: GradientTextProps) { const ref = useRef<HTMLSpanElement>(null); useEffect(() => { const element = ref.current; if (!active || !element || typeof element.animate !== "function") return; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const animation = element.animate(DRIFT, { duration: duration * 1000, iterations: Number.POSITIVE_INFINITY, easing: "linear", }); let onScreen = true; const sync = () => { if (reducedMotion.matches || !onScreen) animation.pause(); else animation.play(); }; // Nothing to animate while the text is scrolled out of view. const observer = new IntersectionObserver(([entry]) => { onScreen = entry.isIntersecting; sync(); }); observer.observe(element); reducedMotion.addEventListener("change", sync); sync(); return () => { observer.disconnect(); reducedMotion.removeEventListener("change", sync); animation.cancel(); }; }, [active, duration]); return ( <span ref={ref} data-active={active || undefined} className={cn( active ? "bg-clip-text font-semibold text-transparent [-webkit-background-clip:text]" : "text-muted-foreground", className, )} style={active ? { backgroundImage: SPECTRUM, backgroundSize: "300% 300%", ...style } : style} {...props} > {children} </span> ); }lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }components/motion/switch.tsx "use client"; import { type ButtonHTMLAttributes, forwardRef, useState } from "react"; import { cn } from "@/lib/utils"; export type SwitchSize = "sm" | "md"; export interface SwitchProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "value" | "defaultValue"> { /** Controlled on state. */ checked?: boolean; /** Starting state when uncontrolled. Default false. */ defaultChecked?: boolean; /** Called with the next state each time the switch is toggled. */ onCheckedChange?: (checked: boolean) => void; /** Track size. Default "md". */ size?: SwitchSize; } const SIZES: Record<SwitchSize, { track: string; thumb: string }> = { sm: { track: "h-5 w-9", thumb: "h-4 w-4 group-data-[state=on]:translate-x-4" }, md: { track: "h-6 w-11", thumb: "h-5 w-5 group-data-[state=on]:translate-x-5" }, }; /** * An on and off switch. Put it inside a label and the whole row becomes the * tap target, with no dead space between the text and the track. */ export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(function Switch( { checked, defaultChecked = false, onCheckedChange, size = "md", className, onClick, ...props }, ref, ) { const [uncontrolled, setUncontrolled] = useState(defaultChecked); const isControlled = checked !== undefined; const on = isControlled ? checked : uncontrolled; return ( <button ref={ref} type="button" role="switch" aria-checked={on} data-state={on ? "on" : "off"} onClick={(event) => { onClick?.(event); if (event.defaultPrevented) return; if (!isControlled) setUncontrolled(!on); onCheckedChange?.(!on); }} className={cn( "group relative inline-flex shrink-0 touch-manipulation items-center rounded-full p-0.5 outline-none", "bg-foreground/15 transition-colors duration-150 ease-out data-[state=on]:bg-accent", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:cursor-not-allowed disabled:opacity-50", // Grows the hit area to about 44px tall without changing the layout. "after:absolute after:-inset-x-1 after:-inset-y-2.5", SIZES[size].track, className, )} {...props} > <span aria-hidden="true" className={cn( "block rounded-full bg-white shadow-[0_1px_2px_rgb(0_0_0/0.25)] transition-[translate] duration-150 ease-out motion-reduce:transition-none", SIZES[size].thumb, )} /> </button> ); });
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| active? | boolean | true | Fills the text with the drifting spectrum. When false the text is muted. Default true. |
| duration? | number | 5.5 | Seconds for one full loop. Default 5.5. |
| className? | string | - | - |
Updated