Text

Shiny Text

A restrained inline shimmer — one narrow light band loops across muted text, for New badges and quiet links.

Preview in your theme

Loading preview…

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

// Everything lives in one owned class, so the reduced-motion branch can drop clip-text and
// transparent together and degrade to plain text instead of leaving a half-transparent
// ghost. That is also why this component needs no "use client".
// The selector is doubled (0,2,0) to outrank any single-class utility: a text-* in the consumer's className cannot silently kill the effect
const KEYFRAMES = `@keyframes st-shine{from{background-position:200% center}to{background-position:0% center}}
.zy-shiny-text.zy-shiny-text{background-image:var(--st-band);background-size:200% auto;background-clip:text;-webkit-background-clip:text;color:transparent;animation:st-shine var(--st-speed,5s) linear infinite}
@media (prefers-reduced-motion:reduce){.zy-shiny-text.zy-shiny-text{background-image:none;color:inherit;animation:none}}`

export type ShinyTextIntensity = "subtle" | "medium"
export type ShinyTextTag = "span" | "div" | "p" | "a" | "strong" | "h2" | "h3" | "h4"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/shiny-text.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ShinyText" component (pure CSS, no
hooks, no animation library — it stays a server component).

Contract
- Export a forwardRef element extending React.HTMLAttributes<HTMLElement>.
- Props: as (rendered tag: span | a | strong | p | div | h2..h4, default
  "span"), children, speed (seconds per sweep, default 5), intensity =
  "subtle" | "medium" (default "subtle"), disabled (default false).
- No size, weight or spacing of its own: it inherits the surrounding
  typography, so the same component works inside a 12px badge and a 24px
  heading.

Behavior
- The shine is a narrow bright band inside a mostly-flat gradient that is
  clipped to the glyphs and slid across them on an infinite loop. Base color
  is var(--muted-foreground), the peak is var(--foreground) (medium) or a
  color-mix of the two (subtle) — both tokens flip with the color scheme, so
  the effect is readable in light and dark without a single override.
- Keep the gradient strictly horizontal ("to right"). The background tiles
  while it scrolls; a slanted gradient would show a visible seam at the tile
  edge, a horizontal one has uniform edges and loops invisibly.
- Loop by animating background-position from 200% to 0% with background-size
  200% auto: one full tile of travel per cycle, band sweeping left to right.
- Put the keyframes, the animation rule and the reduced-motion override in
  ONE hoisted <style href precedence="medium"> tag (React 19 dedupes by
  href). Write the selector twice (.cls.cls) so the rule outranks any single
  utility class the consumer passes — otherwise a text-* class in className
  would silently cancel the transparent color the effect depends on.
- Degradation must be complete, not partial: for prefers-reduced-motion the
  same stylesheet sets background-image: none and color: inherit, and
  disabled={true} simply omits the class. Either way you get ordinary
  inherited text — never a half-transparent ghost.
- Feed the gradient and the duration in through CSS custom properties set
  inline (--band, --speed), NOT as inline background-image/animation: inline
  styles would beat the media-query override and break the fallback.

Rendering & styling
- Semantic tokens only: var(--muted-foreground), var(--foreground), and
  color-mix(in oklab, ...) for the softer peak. No hardcoded colors.
- Because the glyphs are painted by the background, currentColor is
  transparent inside the element: any decoration must carry its own color
  (e.g. underline + decoration-muted-foreground on a link).
- Accessibility: this is a purely decorative paint on real text, so the text
  stays in the document, selectable and announced normally — no aria-hidden,
  no sr-only duplicate. The rendered tag keeps its semantics (a link is
  still a link and takes href / onClick from the consumer).

Customization levers
- Intensity: "subtle" for badges and links (narrow band, low peak),
  "medium" for headings; the two are just different stop positions and peak
  colors — add a third preset by copying one entry.
- Speed: 4-6s reads as ambient; under 2s it starts to look like a loading
  state; over 8s it becomes a slow "breathing" accent.
- Palette: swap the base token (muted-foreground -> primary/60) for a tinted
  shimmer, or the peak (foreground -> primary) to shine in brand color.
- Kill switch: disabled is a prop, so a user setting ("reduce effects") or a
  density mode can turn the shimmer off app-wide without changing markup.
- Tag: as="a" for links, as="strong" inside a sentence, as="h3" for a
  section title — styling is identical, semantics are yours.

Concepts

  • Ambient, not entrance — there is no start and no end state: the band simply keeps passing, which makes it suitable for elements that live on screen permanently (a badge, a nav link) rather than for an arrival animation.
  • Muted base, bright peak — the flat part of the gradient is muted-foreground and only the band reaches foreground, so the text reads as secondary while the highlight supplies the motion; both tokens invert with the theme, so contrast holds in dark mode.
  • Horizontal gradients tile seamlessly — the background repeats as it scrolls, and only a strictly horizontal ramp has uniform tile edges; a diagonal one would flash a seam once per cycle.
  • Complete degradation — under prefers-reduced-motion or disabled, the background and the transparent color are removed together, so the fallback is ordinary text instead of a washed-out ghost.
  • CSS variables in, overrides out — the gradient and duration arrive as custom properties, which keeps the actual declarations inside the component's stylesheet where the reduced-motion media query can still override them (an inline background-image could not be beaten).
  • No currentColor inside clipped text — the element's own color is transparent, so underlines, carets and shadows on the same element need an explicit color token.

On This Page