bottega

effects

Generating Loader

An 'AI is working' indicator: a word (default "Generating") arranged in a circle behind a rotating ring whose inner glow cycles colour from violet to crimson and back. The ring is a transparent disc whose rim is drawn entirely with a stacked inset box-shadow, so it glows softly against any background; the brand palette is mapped onto --foreground (bright core), --primary (mid glow) and --accent (the colour-shift) so it stays theme-robust. Each letter pulses and scales up in a staggered wave timed off the same cycle. Rotation and the letter wave live as keyframes in the co-located CSS module; the per-letter stagger is a CSS custom property set inline (no per-frame React). The word is decorative (aria-hidden) with a polite live region carrying the real status text.

Ported from dexter-st (MIT)

Generating

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/generating-loader

Usage

Usagetsx
<GeneratingLoader />

Props

PropTypeDefaultDescription
labelstringGeneratingWord shown around the rotating ring; each letter pulses in a staggered wave. Default "Generating".
sizenumber | "sm" | "md" | "lg"mdDiameter preset, or pass a number (px) for a custom size. Default "md" (180px).
durationnumber2Seconds for one full cycle (ring rotation + letter wave). Default 2.
aria-labelstringAccessible status text announced to screen readers. Defaults to the label. Pass "" to opt out of the live region.
classNamestringAdditional class names merged onto the root element.
asReact.ElementTypePolymorphic root element (default "div").

Source

generating-loader.tsxtsx
"use client";

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

const SIZE_TIERS = {
  sm: 120,
  md: 180,
  lg: 240,
} as const;

export type GeneratingLoaderProps = {
  /** Word shown around the rotating ring; each letter pulses in a staggered wave. Default "Generating". */
  label?: string;
  /** Diameter preset, or pass a number (px) for a custom size. Default "md" (180px). */
  size?: keyof typeof SIZE_TIERS | number;
  /** Seconds for one full cycle (ring rotation + letter wave). Default 2. */
  duration?: number;
  /**
   * Accessible status text announced to screen readers.
   * Defaults to the label. Pass "" to opt out of the live region.
   */
  "aria-label"?: string;
  /** Additional class names merged onto the root element. */
  className?: string;
  /** Polymorphic root element (default "div"). */
  as?: React.ElementType;
} & Omit<React.HTMLAttributes<HTMLElement>, "aria-label">;

export function GeneratingLoader({
  label = "Generating",
  size = "md",
  duration = 2,
  "aria-label": ariaLabel,
  className = "",
  as,
  style: consumerStyle,
  ...props
}: GeneratingLoaderProps) {
  const Root = (as ?? "div") as React.ElementType;

  // Mount-gate + null-safe reduced-motion check (contract).
  // useReducedMotion() is null on SSR but a concrete boolean on first client
  // render, so we keep the server/first-client render byte-identical (static
  // base) and only switch on the animation values after mount.
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
  const animate = mounted && !reduce;

  const px = typeof size === "number" ? size : SIZE_TIERS[size];
  const letters = Array.from(label);
  const status = ariaLabel === undefined ? label : ariaLabel;

  // Required vars applied last so consumer style can never drop them.
  const rootStyle: React.CSSProperties & Record<string, string> = {
    ...consumerStyle,
    "--loader-size": `${px}px`,
    "--loader-duration": `${duration}s`,
  } as React.CSSProperties & Record<string, string>;

  const rootClassName = [styles.root, animate && styles.animate, className]
    .filter(Boolean)
    .join(" ");

  // Polymorphic root via createElement — avoids JSX widening `as` over every
  // intrinsic element. Runtime is identical to <Root>…</Root>.
  return createElement(
    Root,
    {
      ...props,
      className: rootClassName,
      style: rootStyle,
      role: "status",
      "aria-live": "polite",
      "aria-busy": "true",
    },
    // Letters are decorative; the live region (below) carries the real text so
    // screen readers don't read out a wall of single characters.
    letters.map((char, i) =>
      createElement(
        "span",
        {
          key: `${char}-${i}`,
          "aria-hidden": "true",
          className: styles.letter,
          // Per-letter stagger as a CSS var; the keyframe is shared in the module.
          style: { "--letter-delay": `${(i * duration) / 20}s` } as React.CSSProperties,
        },
        char,
      ),
    ),
    <span key="ring" aria-hidden="true" className={styles.ring} />,
    status
      ? createElement("span", { key: "sr", className: styles.srOnly }, status)
      : null,
  );
}

Dependencies

  • motion
  • @bottega/tokens