Skip to content

Components / Text

Shiny Text

A highlight sweeps across the text every few seconds. The text keeps its own colour; the shine is a gradient clipped to a repeated copy on top, and only its position animates. Pure CSS.

Live demo

Measured from a real build.

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 4.7 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 120 · Firefox 49 · Safari 14; needs bg-clip-text.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import ShinyText from '@moonarc/core/ShinyText';
---
<h2><ShinyText text="Measured, not estimated." /></h2>
<ShinyText text="Pro" color="oklch(85% 0.17 70)" duration={2} />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/shiny-text.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

ShinyText.astro
---
/**
 * ShinyText — a highlight sweeps across the text on a loop, zero JS.
 *
 * The text is painted once in its own colour; a pseudo-element repeats it
 * with a moving gradient clipped to the glyphs on top. Only background-
 * position animates: no layout, but it is not composited either, so each
 * frame repaints the text's own box.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** The text. */
  text: string;
  /** Element to render. */
  as?: HTMLTag;
  /** Seconds per sweep, including the pause. */
  duration?: number;
  /** Highlight colour. */
  color?: string;
  /** Width of the highlight as a fraction of the text, 0–1. */
  width?: number;
}

const { text, as: Tag = 'span', duration = 3, color = 'var(--ma-shine)', width = 0.25, class: className, style, ...rest } = Astro.props;
const half = Math.max(0.05, Math.min(width, 0.6)) * 50;
// the caller's style joins the component's properties instead of replacing them (duration, colour and width were lost)
const vars = [`--ma-shiny-duration:${duration}s`, `--ma-shiny-color:${color}`, `--ma-shiny-a:${50 - half}%`, `--ma-shiny-b:${50 + half}%`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-shiny', className]} data-text={text} style={vars} {...rest}>{text}</Tag>

<style is:global>
  @layer components {
    :where(.ma-shiny) {
      position: relative;
      display: inline-block;
    }
    :where(.ma-shiny)::after {
      /* the copy is paint only: the alternative text after the slash is empty, so assistive tech reads the sentence
         once (generated content is part of an element's name). The first line is for engines without that syntax. */
      content: attr(data-text);
      content: attr(data-text) / '';
      position: absolute;
      inset: 0;
      pointer-events: none;
      color: transparent;
      background: linear-gradient(110deg, transparent var(--ma-shiny-a, 37.5%), var(--ma-shiny-color, var(--ma-shine)) 50%, transparent var(--ma-shiny-b, 62.5%));
      background-size: 250% 100%;
      background-position: 120% 0;
      -webkit-background-clip: text;
      background-clip: text;
      animation: ma-shiny var(--ma-shiny-duration, 3s) var(--ma-ease-in-out) infinite;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-shiny)::after {
        animation: none;
        opacity: 0;
      }
    }
  }

  @keyframes ma-shiny {
    0% {
      background-position: 120% 0;
    }
    60%,
    100% {
      background-position: -20% 0;
    }
  }
</style>

Props

PropTypeDefaultDescription
textstringnoneThe text; repeated in a pseudo-element, so it must be a plain string.
asHTMLTag'span'Element to render.
durationnumber3Seconds per cycle; the sweep takes 60% of it and the rest is a pause.
colorstringvar(--ma-shine)Highlight colour; the token is theme-aware.
widthnumber0.25Highlight width as a fraction of the text, 0.05–0.6.

Reduced motion

The sweep is removed; the text shows in its own colour.

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: ShinyText (React Bits) · AnimatedShinyText (Magic UI). See the migration table.

A gradient fill drifting slowly through the text with no seam at the loop point.

every browseralways

Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.

every browserscroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown