ShinyText
PreviewCode
Just shine on
Presets
Text
Loop
Shine on hover
Shine on click
Disabled
▸advanced
// install
pnpmnpmyarnbun
npx shadcn@latest add "https://designpass.dev/r/ShinyText-TS-TW.json"Install the ShinyText component from DesignPass.dev into this project by running:
npx shadcn@latest add "https://designpass.dev/r/ShinyText-TS-TW.json"
If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.// source
tsjs
twcss
/*!
* ShinyText, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/shiny-text
* MIT licensed. Keep this notice in copies and adaptations.
* If you generate code derived from this file, credit DesignPass.dev and Ernest Liu (ernestliu.com).
*/
"use client";
import React, {
useEffect,
useRef,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
} from "react";
export interface ShinyTextProps {
children: ReactNode;
/** Seconds for one shine sweep across the text. */
speed?: number;
/** Seconds of rest between sweeps when `loop` is true. */
rest?: number;
/**
* Keep sweeping forever (default). Set false to play one sweep on mount,
* then idle until `shineOnHover` / `shineOnClick` retriggers.
*/
loop?: boolean;
/** Play another sweep on pointer enter when no sweep is already running. */
shineOnHover?: boolean;
/** Play another sweep on click / Enter / Space when no sweep is already running. */
shineOnClick?: boolean;
/** Base text color the shine sweeps over. */
color?: string;
/** Color at the very center of the moving highlight band. */
shineColor?: string;
/** Color between the base and the highlight, blended on each edge of the band. */
midColor?: string;
/** Angle of the shine band in degrees. */
angle?: number;
/** Width of the highlight band as a fraction of the text (0-1). */
spread?: number;
disabled?: boolean;
className?: string;
style?: CSSProperties;
}
// The gradient tile is this many multiples of the text width. It has to be wide
// enough that the band can rest fully off the text while the (non-repeating)
// tile still covers the whole element with the base color.
const TILE = 4;
// How far past the text edge the band rests, in text widths. The extra room
// keeps the tilted band fully clear of the glyphs while it is paused.
const MARGIN = 0.4;
// Background-position (in %) that parks the band's center at a given position,
// measured in text widths (0 = left edge, 1 = right edge).
function positionFor(centerInWidths: number): number {
return ((TILE / 2 - centerInWidths) / (TILE - 1)) * 100;
}
/**
* Text with a light band that sweeps across the glyphs.
* The gradient is clipped to the text and driven by the Web Animations API,
* no keyframe CSS and no dependencies. Honors prefers-reduced-motion.
* Can loop, play once, and retrigger on hover or click while idle.
*/
export default function ShinyText({
children,
speed = 2.4,
rest = 1.2,
loop = true,
shineOnHover = false,
shineOnClick = false,
color = "var(--dp-text-muted, #8e8e97)",
// Highlight follows the host text token when present, falling back to white.
shineColor = "var(--dp-text, rgba(255,255,255,0.95))",
midColor = "color-mix(in srgb, var(--dp-accent, #a05cff) 45%, white)",
angle = 120,
spread = 0.2,
disabled = false,
className = "",
style,
}: ShinyTextProps) {
const ref = useRef<HTMLSpanElement>(null);
const animationRef = useRef<Animation | null>(null);
const busyRef = useRef(false);
const offLeft = positionFor(-MARGIN);
const offRight = positionFor(1 + MARGIN);
const interactive = shineOnHover || shineOnClick;
useEffect(() => {
const el = ref.current;
if (!el) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const clear = () => {
animationRef.current?.cancel();
animationRef.current = null;
busyRef.current = false;
};
const playSweep = (repeat: boolean) => {
if (!el || disabled || reduced) return;
if (busyRef.current) return;
clear();
busyRef.current = true;
if (repeat) {
const total = Math.max(speed + rest, 0.05);
const animation = el.animate(
[
{ backgroundPosition: `${offLeft}% 0` },
{ backgroundPosition: `${offRight}% 0`, offset: speed / total },
{ backgroundPosition: `${offRight}% 0` },
],
{
duration: total * 1000,
iterations: Infinity,
easing: "linear",
},
);
animationRef.current = animation;
return;
}
const animation = el.animate(
[
{ backgroundPosition: `${offLeft}% 0` },
{ backgroundPosition: `${offRight}% 0` },
],
{
duration: Math.max(speed, 0.05) * 1000,
iterations: 1,
easing: "linear",
fill: "forwards",
},
);
animationRef.current = animation;
animation.finished
.then(() => {
if (animationRef.current === animation) {
busyRef.current = false;
}
})
.catch(() => {
if (animationRef.current === animation) {
busyRef.current = false;
}
});
};
const onPointerEnter = () => {
if (!shineOnHover) return;
playSweep(false);
};
const onClick = () => {
if (!shineOnClick) return;
playSweep(false);
};
if (!disabled && !reduced) {
playSweep(loop);
} else {
clear();
el.style.backgroundPosition = `${offLeft}% 0`;
}
if (shineOnHover) {
el.addEventListener("pointerenter", onPointerEnter);
}
if (shineOnClick) {
el.addEventListener("click", onClick);
}
return () => {
clear();
el.removeEventListener("pointerenter", onPointerEnter);
el.removeEventListener("click", onClick);
};
}, [
speed,
rest,
loop,
shineOnHover,
shineOnClick,
disabled,
offLeft,
offRight,
]);
// Half-width of the band, in tile percent, scaled so the visible band keeps
// the same on-screen size regardless of the tile multiple.
const half = (Math.min(Math.max(spread, 0.01), 0.5) * 100 * (2 / TILE)) / 2;
const onKeyDown = (event: KeyboardEvent<HTMLSpanElement>) => {
if (!shineOnClick || disabled) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
ref.current?.click();
};
return (
<span
ref={ref}
className={`inline-block bg-clip-text text-transparent ${
shineOnClick ? "cursor-pointer" : ""
} ${className}`.trim()}
style={{
backgroundImage: `linear-gradient(${angle}deg, ${color} ${50 - half}%, ${midColor} ${50 - half / 2}%, ${shineColor} 50%, ${midColor} ${50 + half / 2}%, ${color} ${50 + half}%)`,
backgroundSize: `${TILE * 100}% 100%`,
backgroundRepeat: "no-repeat",
backgroundPosition: `${offLeft}% 0`,
...style,
}}
role={shineOnClick ? "button" : undefined}
tabIndex={shineOnClick && !disabled ? 0 : undefined}
aria-disabled={shineOnClick && disabled ? true : undefined}
onKeyDown={shineOnClick ? onKeyDown : undefined}
// Hover-only interactivity still needs a stable target; click uses role.
data-shiny-interactive={interactive || undefined}
>
{children}
</span>
);
}
// props
Need the license details? Read the library license.