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)
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/generating-loaderUsage
<GeneratingLoader />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | Generating | Word shown around the rotating ring; each letter pulses in a staggered wave. Default "Generating". |
| size | number | "sm" | "md" | "lg" | md | Diameter preset, or pass a number (px) for a custom size. Default "md" (180px). |
| duration | number | 2 | Seconds for one full cycle (ring rotation + letter wave). Default 2. |
| aria-label | string | — | Accessible status text announced to screen readers. Defaults to the label. Pass "" to opt out of the live region. |
| className | string | Additional class names merged onto the root element. | |
| as | React.ElementType | — | Polymorphic root element (default "div"). |
Source
"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