Skip to content
Display

Display

ShinyGradientText

Text filled with a narrow metal sweep, legible on both light and dark grounds.

npx bezel-add add shiny-gradient-text

packages/ui/src/display/ShinyGradientText.tsx · 94 lines

"use client";

import { useEffect, useRef, useState } from "react";
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";

/*
 * ShinyGradientText: a line of text filled with a slow metal sweep.
 *
 * The ramp is deliberately narrow. Each tone is one warm family moving through
 * value with a single specular band: "paper" is ink warming into the brick
 * accent, "void" is champagne over gold peaking at white. Wide multi-hue
 * gradients read cheap and, worse, go pale mid-sweep and lose the text.
 *
 * Legibility is the constraint, because this is type before it is ornament.
 * Every stop clears 4.5:1 on its own ground, so no moment of the loop is
 * harder to read than the still frame: the weakest paper stop is the brick at
 * 8.1:1 on white, the weakest void stop the gold at 6.7:1 on --bz-void-raised.
 *
 * Colours resolve from the --bz-* tokens, so a themed app carries the sweep
 * with it. For anything else, set --bz-sgt-base, --bz-sgt-halo or
 * --bz-sgt-shine on the element.
 *
 * The image is twice the box wide and travels exactly one image width per
 * cycle, so the loop closes without a seam. It pauses off screen and in hidden
 * tabs. Reduced motion parks the specular mid-line instead of dropping the
 * fill, which leaves a still line that still looks finished.
 */

const CSS = `
.bz-sgt{display:inline-block;color:var(--bz-sgt-base);background-image:linear-gradient(100deg,var(--bz-sgt-base) 0%,var(--bz-sgt-base) 12%,var(--bz-sgt-halo) 32%,var(--bz-sgt-shine) 50%,var(--bz-sgt-halo) 68%,var(--bz-sgt-base) 88%,var(--bz-sgt-base) 100%);background-size:200% 100%;background-position:0% center;animation:bz-sgt-sweep var(--bz-sgt-dur,6s) linear infinite}
.bz-sgt[data-tone="paper"]{--bz-sgt-base:var(--bz-ink,#0a0a0a);--bz-sgt-halo:#541d17;--bz-sgt-shine:var(--bz-accent,#912c22)}
.bz-sgt[data-tone="void"]{--bz-sgt-base:#ecdeb3;--bz-sgt-halo:var(--bz-gold-fill,#c9a227);--bz-sgt-shine:var(--bz-void-ink,#ffffff)}
@supports (color:color-mix(in srgb,red 50%,blue)){.bz-sgt[data-tone="paper"]{--bz-sgt-halo:color-mix(in srgb,var(--bz-ink,#0a0a0a) 45%,var(--bz-accent,#912c22))}.bz-sgt[data-tone="void"]{--bz-sgt-base:color-mix(in srgb,var(--bz-gold-fill,#c9a227) 35%,var(--bz-void-ink,#ffffff))}}
@supports ((-webkit-background-clip:text) or (background-clip:text)){.bz-sgt{-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent}}
@keyframes bz-sgt-sweep{from{background-position:200% center}to{background-position:0% center}}
.bz-sgt[data-running="false"]{animation-play-state:paused}
@media (prefers-reduced-motion:reduce){.bz-sgt{animation:none;background-position:50% center}}
`;

export type ShinyGradientTextProps = HTMLAttributes<HTMLSpanElement> & {
  children: ReactNode;
  /** The ground the line sits on. Paper is the library default surface. */
  tone?: "paper" | "void";
  /** Seconds for one pass of the specular. Slow reads considered, fast reads restless. */
  duration?: number;
};

export function ShinyGradientText({
  children,
  className = "",
  tone = "paper",
  duration = 6,
  style,
  ...rest
}: ShinyGradientTextProps) {
  const ref = useRef<HTMLSpanElement>(null);
  const [onscreen, setOnscreen] = useState(false);
  const [visible, setVisible] = useState(true);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (!("IntersectionObserver" in window)) {
      setOnscreen(true);
      return;
    }
    const io = new IntersectionObserver(([entry]) => setOnscreen(entry.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, []);

  useEffect(() => {
    const sync = () => setVisible(document.visibilityState !== "hidden");
    sync();
    document.addEventListener("visibilitychange", sync);
    return () => document.removeEventListener("visibilitychange", sync);
  }, []);

  return (
    <>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <span
        ref={ref}
        className={`bz-sgt ${className}`.trim()}
        data-tone={tone}
        data-running={onscreen && visible ? "true" : "false"}
        style={{ ...style, ["--bz-sgt-dur" as string]: `${duration}s` } as CSSProperties}
        {...rest}
      >
        {children}
      </span>
    </>
  );
}