bottega

effects

Text Shimmer

A sweep of luminance — a single soft highlight band glides left-to-right across a readable dim base (--foreground) on a slow infinite loop via background-clip:text. The band is tinted toward --primary (a soft sheen, not a harsh loading flash) and the oversized background-size sweeps the peak across while the base text stays readable throughout the cycle. For hero headings, labels, and status text. Configurable tag, duration, spread, and colors; disabled prop renders static text.

Generating response


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/text-shimmer

Usage

Usagetsx
<TextShimmer>
  {/* children */}
</TextShimmer>

Props

PropTypeDefaultDescription
children*React.ReactNodeText content to shimmer. Required.
asReact.ElementTypespanRendered element tag. Default: "span". Accepts "p", "h1"–"h6", "div".
durationnumber2.4Full sweep cycle in seconds. Default: 2.4. Longer = more refined.
spreadnumber0.35Width of the shimmer band as a fraction of total text width (0–1). Default: 0.35.
shimmerColorstringvar(--primary)Peak color of the sweep band. Default: var(--primary). Any CSS color.
baseColorstringRest color of the text. Default: 45% foreground (dim but readable). Any CSS color.
disabledbooleanfalseIf true, renders plain static text with baseColor, no animation.
classNamestringMerged into the root element for sizing/layout overrides.

Source

text-shimmer.tsxtsx
"use client";

import { createElement, useEffect, useState } from "react";
import { useReducedMotion } from "motion/react";
import type React from "react";
import styles from "./text-shimmer.module.css";

export type TextShimmerProps = {
  /** Text content to shimmer. Required. */
  children: React.ReactNode;
  /** Rendered element tag. Default: "span". Accepts "p", "h1"–"h6", "div". */
  as?: React.ElementType;
  /** Full sweep cycle in seconds. Default: 2.4. Longer = more refined. */
  duration?: number;
  /** Width of the shimmer band as a fraction of total text width (0–1). Default: 0.35. */
  spread?: number;
  /** Peak color of the sweep band. Default: var(--primary). Any CSS color. */
  shimmerColor?: string;
  /** Rest color of the text. Default: 45% foreground (dim but readable). Any CSS color. */
  baseColor?: string;
  /** If true, renders plain static text with baseColor, no animation. */
  disabled?: boolean;
  /** Merged into the root element for sizing/layout overrides. */
  className?: string;
} & Omit<React.HTMLAttributes<HTMLElement>, "color">;

export function TextShimmer({
  children,
  as: Tag = "span",
  duration = 2.4,
  spread = 0.35,
  shimmerColor = "var(--primary)",
  baseColor,
  disabled = false,
  className = "",
  style,
  ...props
}: TextShimmerProps) {
  // Mount-gate + null-safe reduced-motion check (contract §47). The animated
  // class is only applied AFTER hydration for non-reduced-motion users, so the
  // server HTML and the client's first render are byte-identical static text
  // (no React #418), and reduced-motion users never see a flash.
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
  const animate = mounted && !reduce && !disabled;

  // Clamp spread to the valid fraction range, then map to a half-band percent
  // offset from the gradient centre (50%). spread 0.35 -> ±17.5%.
  const clampedSpread = Math.min(Math.max(spread, 0), 1);
  const spreadPercent = `${(clampedSpread * 50).toFixed(2)}%`;

  // Polymorphic tag via createElement — avoids JSX widening `as` over every
  // intrinsic element (which collapses `children` to `never`).
  return createElement(
    Tag,
    {
      className: [styles.shimmer, animate ? styles.animated : "", className]
        .filter(Boolean)
        .join(" "),
      // Inline custom properties consumed by the CSS module. Colors default
      // to contract tokens; consumers may override with any CSS color.
      style: {
        "--shimmer-base": baseColor,
        "--shimmer-peak": shimmerColor,
        "--shimmer-duration": `${duration}s`,
        "--shimmer-spread": spreadPercent,
        ...style,
      } as React.CSSProperties,
      ...props,
    },
    children,
  );
}

Dependencies

  • motion
  • @bottega/tokens