Text loaders
Five text-based loaders: a typing label, an ellipsis, a letter scramble, a wave and a blinking cursor. Each is sized and token-driven, and takes a label prop.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/loaders-textDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/loaders-text.tsx"use client"
import type * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Text loader family: 5 decorative indicators with a label plus typographic motion. Colour comes ONLY from tokens (text-*). Size = the font scale. When `label` is a string it is split into letters; when it is not, it renders as-is. DELIBERATE: it keeps moving under reduced-motion too. The delays are index-derived and fixed, nothing random. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const box: Record<StyledSize, string> = {
sm: "size-4",
md: "size-5",
lg: "size-6",
xl: "size-8",
}
const font: Record<StyledSize, string> = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
xl: "text-lg",
}
type Props = React.ComponentProps<"div"> & { size?: StyledSize; label?: string }
/* Typing: label sozcugu + animasyonlu ... noktalari. */
export function TypingLoader({ className, size = "md", label = "Loading", ...props }: Props) {
const dots = [0, 1, 2]
return (
<div
data-slot="styled-loader"
role="status"
aria-label={label}
className={cn("inline-flex items-center gap-1 font-medium text-primary", font[size], className)}
{...props}
>
<span>{label}</span>
<span className="inline-flex">
{dots.map((i) => (
<motion.span
key={i}
className="inline-block"
animate={{ opacity: [0.2, 1, 0.2] }}
transition={{ duration: 1.2, ease: "easeInOut", repeat: Infinity, delay: i * 0.2 }}
>
.
</motion.span>
))}
</span>
</div>
)
}
/* Ellipsis: a dot cycle . .. ... (the width changes). */
export function EllipsisLoader({ className, size = "md", label = "Loading", ...props }: Props) {
const dots = [0, 1, 2]
return (
<div
data-slot="styled-loader"
role="status"
aria-label={label}
className={cn("inline-flex items-center font-medium text-info", font[size], className)}
{...props}
>
{dots.map((i) => (
<motion.span
key={i}
className="inline-block"
animate={{ opacity: [0, 1, 0] }}
transition={{ duration: 1.5, ease: "easeInOut", repeat: Infinity, delay: i * 0.25 }}
>
.
</motion.span>
))}
</div>
)
}
/* letter-splitting yardimcisi: label string ise harflere boler. */
function letters(label: string) {
return Array.from(label)
}
/* Scramble: the letters of a short label jump and shake. */
export function ScrambleLoader({ className, size = "md", label = "Loading", ...props }: Props) {
const chars = letters(label)
return (
<div
data-slot="styled-loader"
role="status"
aria-label={label}
className={cn("inline-flex items-center font-medium text-brand", font[size], className)}
{...props}
>
{chars.map((ch, i) => (
<motion.span
key={i}
className="inline-block whitespace-pre"
animate={{ y: [0, -3, 0], opacity: [0.5, 1, 0.5] }}
transition={{ duration: 0.9, ease: "easeInOut", repeat: Infinity, delay: i * 0.06 }}
>
{ch}
</motion.span>
))}
</div>
)
}
/* WaveText: label harfleri dalga halinde zipla. */
export function WaveTextLoader({ className, size = "md", label = "Loading", ...props }: Props) {
const chars = letters(label)
return (
<div
data-slot="styled-loader"
role="status"
aria-label={label}
className={cn("inline-flex items-center font-medium text-success", font[size], className)}
{...props}
>
{chars.map((ch, i) => (
<motion.span
key={i}
className="inline-block whitespace-pre"
animate={{ y: ["0%", "-45%", "0%"] }}
transition={{ duration: 1, ease: "easeInOut", repeat: Infinity, delay: i * 0.08 }}
>
{ch}
</motion.span>
))}
</div>
)
}
/* Blink: label + yanip sonen blok imlec. */
export function BlinkLoader({ className, size = "md", label = "Loading", ...props }: Props) {
const reduce = useReducedMotion()
return (
<div
data-slot="styled-loader"
role="status"
aria-label={label}
className={cn("inline-flex items-center gap-1 font-medium text-warning", font[size], className)}
{...props}
>
<span>{label}</span>
<motion.span
aria-hidden="true"
className="inline-block h-[1em] w-[0.5ch] bg-current align-middle"
animate={{ opacity: reduce ? [0.3, 1, 0.3] : [1, 1, 0, 0] }}
transition={{ duration: 1, ease: "linear", repeat: Infinity, times: reduce ? [0, 0.5, 1] : [0, 0.5, 0.5, 1] }}
/>
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Typing
A label followed by animated ellipsis dots.
import { TypingLoader } from "@/components/ui/loaders-text"
<TypingLoader />Ellipsis
Three dots cycling through . .. ...
import { EllipsisLoader } from "@/components/ui/loaders-text"
<EllipsisLoader />Scramble
The label letters bob in place.
import { ScrambleLoader } from "@/components/ui/loaders-text"
<ScrambleLoader />Wave
The label letters ripple in a wave.
import { WaveTextLoader } from "@/components/ui/loaders-text"
<WaveTextLoader />Blink
The label with a blinking block cursor.
import { BlinkLoader } from "@/components/ui/loaders-text"
<BlinkLoader />ai2 Text loaders: 5 styled variations on the token system
The ai2 Text loaders are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around text-based loading indicators. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion staggers the letters and cycles the dots and cursor. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the text keeps animating on purpose, like the base Spinner, because a frozen loader reads as a hang.
What is in the ai2 Text loaders?
5 exports in one file: Typing, Ellipsis, Scramble, Wave and Blink. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion staggers the letters and cycles the dots and cursor.
- Reduced-motion aware: Under prefers-reduced-motion, the text keeps animating on purpose, like the base Spinner, because a frozen loader reads as a hang.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Text loaders sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.