Skip to content

Bars Loader

Equalizer-style bars.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/bars-loader.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

  3. Copy the source

    components/ui/bars-loader/bars-loader.tsx
    "use client";
    
    import type { ComponentProps } from "react";
    import { motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    
    export interface BarsLoaderProps
      extends Omit<ComponentProps<"span">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Number of bars. @default 5 */
      bars?: number;
      /** Height in px. @default 20 */
      height?: number;
      /** Bar width in px. @default 3 */
      barWidth?: number;
      /** Space between bars in px. @default 2 */
      gap?: number;
      /** Bounce; when false the bars settle low, like paused audio. @default true */
      playing?: boolean;
      /** Speed multiplier. @default 1 */
      speed?: number;
      /** Accessible label. @default "Loading" */
      label?: string;
    }
    
    // Hand-tuned so neighboring bars never move in lockstep.
    const PATTERNS = [
      [0.35, 1, 0.5, 0.85, 0.35],
      [0.6, 0.3, 1, 0.45, 0.6],
      [0.45, 0.9, 0.35, 1, 0.45],
      [0.8, 0.4, 0.7, 0.3, 0.8],
      [0.3, 0.75, 0.95, 0.5, 0.3],
    ];
    
    export function BarsLoader({
      bars = 5,
      height = 20,
      barWidth = 3,
      gap = 2,
      playing = true,
      speed = 1,
      label = "Loading",
      className,
      ...props
    }: BarsLoaderProps) {
      const reduceMotion = useReducedMotion();
      return (
        <span role="status" aria-label={label} className={cn("inline-flex items-end", className)} style={{ height, gap }} {...props}>
          {Array.from({ length: bars }, (_, i) => (
            <motion.span
              key={i}
              aria-hidden="true"
              className="block h-full origin-bottom rounded-full bg-current"
              style={{ width: barWidth }}
              initial={{ scaleY: 0.3 }}
              animate={
                !playing
                  ? { scaleY: 0.25, opacity: 1 }
                  : reduceMotion
                    ? { scaleY: 0.55, opacity: [1, 0.4, 1] }
                    : { scaleY: PATTERNS[i % PATTERNS.length], opacity: 1 }
              }
              transition={
                !playing
                  ? { duration: 0.4, ease: "easeOut" }
                  : { duration: (reduceMotion ? 1.6 : 0.9 + (i % 3) * 0.17) / speed, delay: i * 0.08, repeat: Infinity, ease: "easeInOut" }
              }
            />
          ))}
        </span>
      );
    }

Usage

import { BarsLoader } from "@/components/ui/bars-loader/bars-loader";

<BarsLoader />

// As a "now playing" indicator
<BarsLoader playing={isPlaying} label={isPlaying ? "Playing" : "Paused"} />

API reference

PropTypeDefaultDescription
barsnumber5Number of bars.
heightnumber20Height in px.
barWidthnumber3Bar width in px.
gapnumber2Space between bars in px.
playingbooleantrueBounce; when false the bars settle low, like paused audio.
speednumber1Speed multiplier.
labelstring"Loading"Accessible label.

Also accepts every native <span> attribute, including ref and className.

  • Each bar follows its own hand-tuned rhythm, so neighbors never move in lockstep. Bars grow from the bottom with scaleY only.
  • playing={false} settles the bars low, like paused audio. Uses currentColor.

Accessibility

  • A status element with an accessible label; the bars are aria-hidden. Update `label` when the meaning changes (Playing / Paused).
  • With reduced motion enabled, bars hold still and gently fade.