Pixel Loader
A 3x3 grid of cells that twinkle on independent, randomized cycles, paired with a shimmering label and an optional live elapsed timer.
Running tests0.0s
import { PixelLoader } from "@/components/motion/pixel-loader";
export function PixelLoaderPreview() {
return <PixelLoader label="Running tests" showElapsed />;
}
"use client";
// easeui.dev/components/agents/pixel-loader
import { useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
const CELLS = 9;
const MIN_PULSE_MS = 500;
const MAX_PULSE_MS = 1100;
function useElapsed(active: boolean) {
const [ms, setMs] = useState(0);
useEffect(() => {
if (!active) return;
const startedAt = Date.now();
const timer = setInterval(() => setMs(Date.now() - startedAt), 100);
return () => clearInterval(timer);
}, [active]);
const seconds = ms / 1000;
return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${(seconds % 60).toFixed(1)}s`;
}
export interface PixelLoaderProps {
/** Status label shown beside the grid. Default "Thinking". */
label?: string;
/** Shows a live elapsed-time counter after the label. Default false. */
showElapsed?: boolean;
className?: string;
}
/**
* A 3x3 grid of cells that twinkle on their own independent, randomized
* cycles, for work that runs long enough to want a sense of progress. No
* two cells share a rhythm, so nothing about it reads as a sweep or a
* pattern. Pairs a shimmering label with an optional live timer. Reduced
* motion holds the grid dim; the timer still ticks.
*/
export function PixelLoader({ label = "Thinking", showElapsed = false, className }: PixelLoaderProps) {
const cellRefs = useRef<(HTMLSpanElement | null)[]>([]);
const elapsed = useElapsed(showElapsed);
const reduce = useReducedMotion();
useEffect(() => {
if (reduce) return;
const animations = cellRefs.current.map((cell) =>
cell?.animate([{ opacity: 0.15 }, { opacity: 1 }, { opacity: 0.15 }], {
duration: MIN_PULSE_MS + Math.random() * (MAX_PULSE_MS - MIN_PULSE_MS),
delay: Math.random() * MAX_PULSE_MS,
iterations: Number.POSITIVE_INFINITY,
easing: "ease-in-out",
}),
);
return () => {
for (const animation of animations) animation?.cancel();
};
}, [reduce]);
return (
<div role="status" className={cn("inline-flex items-center gap-2.5", className)}>
<span aria-hidden="true" className="grid grid-cols-3 gap-[3px]">
{Array.from({ length: CELLS }, (_, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed 3x3 grid, cells never reorder.
key={index}
ref={(el) => {
cellRefs.current[index] = el;
}}
className="h-[5px] w-[5px] rounded-[1px] bg-foreground"
style={{ opacity: reduce ? 0.5 : 0.15 }}
/>
))}
</span>
<span
className={cn(
"inline-block text-sm font-medium",
reduce
? "text-muted-foreground"
: "animate-shimmer bg-[length:200%_100%] bg-clip-text text-transparent [-webkit-background-clip:text]",
)}
style={
reduce
? undefined
: {
backgroundImage:
"linear-gradient(90deg, var(--muted-foreground) 30%, var(--foreground) 50%, var(--muted-foreground) 70%)",
}
}
>
{label}
</span>
{showElapsed ? (
<span className="font-mono text-xs text-muted-foreground tabular-nums">{elapsed}</span>
) : null}
</div>
);
}
Installation
$ bunx --bun shadcn add @easeui/pixel-loader
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx motion tailwind-merge - 3
Add the source files
components/motion/pixel-loader.tsx "use client"; // easeui.dev/components/agents/pixel-loader import { useReducedMotion } from "motion/react"; import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; const CELLS = 9; const MIN_PULSE_MS = 500; const MAX_PULSE_MS = 1100; function useElapsed(active: boolean) { const [ms, setMs] = useState(0); useEffect(() => { if (!active) return; const startedAt = Date.now(); const timer = setInterval(() => setMs(Date.now() - startedAt), 100); return () => clearInterval(timer); }, [active]); const seconds = ms / 1000; return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${(seconds % 60).toFixed(1)}s`; } export interface PixelLoaderProps { /** Status label shown beside the grid. Default "Thinking". */ label?: string; /** Shows a live elapsed-time counter after the label. Default false. */ showElapsed?: boolean; className?: string; } /** * A 3x3 grid of cells that twinkle on their own independent, randomized * cycles, for work that runs long enough to want a sense of progress. No * two cells share a rhythm, so nothing about it reads as a sweep or a * pattern. Pairs a shimmering label with an optional live timer. Reduced * motion holds the grid dim; the timer still ticks. */ export function PixelLoader({ label = "Thinking", showElapsed = false, className }: PixelLoaderProps) { const cellRefs = useRef<(HTMLSpanElement | null)[]>([]); const elapsed = useElapsed(showElapsed); const reduce = useReducedMotion(); useEffect(() => { if (reduce) return; const animations = cellRefs.current.map((cell) => cell?.animate([{ opacity: 0.15 }, { opacity: 1 }, { opacity: 0.15 }], { duration: MIN_PULSE_MS + Math.random() * (MAX_PULSE_MS - MIN_PULSE_MS), delay: Math.random() * MAX_PULSE_MS, iterations: Number.POSITIVE_INFINITY, easing: "ease-in-out", }), ); return () => { for (const animation of animations) animation?.cancel(); }; }, [reduce]); return ( <div role="status" className={cn("inline-flex items-center gap-2.5", className)}> <span aria-hidden="true" className="grid grid-cols-3 gap-[3px]"> {Array.from({ length: CELLS }, (_, index) => ( <span // biome-ignore lint/suspicious/noArrayIndexKey: a fixed 3x3 grid, cells never reorder. key={index} ref={(el) => { cellRefs.current[index] = el; }} className="h-[5px] w-[5px] rounded-[1px] bg-foreground" style={{ opacity: reduce ? 0.5 : 0.15 }} /> ))} </span> <span className={cn( "inline-block text-sm font-medium", reduce ? "text-muted-foreground" : "animate-shimmer bg-[length:200%_100%] bg-clip-text text-transparent [-webkit-background-clip:text]", )} style={ reduce ? undefined : { backgroundImage: "linear-gradient(90deg, var(--muted-foreground) 30%, var(--foreground) 50%, var(--muted-foreground) 70%)", } } > {label} </span> {showElapsed ? ( <span className="font-mono text-xs text-muted-foreground tabular-nums">{elapsed}</span> ) : null} </div> ); }lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| label? | string | Thinking | Status label shown beside the grid. Default "Thinking". |
| showElapsed? | boolean | false | Shows a live elapsed-time counter after the label. Default false. |
| className? | string | - | - |
Updated