Skip to main content

ns-ui

Loader Spring Bars

An ambient loading glyph shaped as a row of leaf springs on a rail: one displacement keyframe, staggered per bar by negative animation-delay, reads as a single pulse of energy travelling down the rack rather than bars animating in isolation.

Use when an indeterminate wait in a horizontal slot wider than it is tall (a caption row, a toolbar, the strip under a search field) where a square glyph would waste the space, since 3 to 9 bars share one keyframe phased by negative delay so a single pulse travels the rack; pick loader-iris or loader-die-tumble instead when the slot is square and inline, or loader-pendulum-sync when the row can be much wider and the wait runs into minutes.

Install

npx shadcn add https://design.helpmarq.com/r/loader-spring-bars.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Source
registry/core/loader-spring-bars/component.tsx
"use client";

// ---------------------------------------------------------------------------
// LathRack — an ambient loading glyph shaped as a row of leaf springs
// mounted on a rail, one shared displacement passing through them left to
// right. Each lath runs the identical keyframe (compressed rest -> rise,
// past its own height, on ease-out-expo -> a two-beat decaying overshoot on
// the house spring curve -> back to rest) but at a negative animation-delay
// staggered by index, so what's actually one waveform looks like a pulse
// of energy travelling down the rack rather than N bars animating in
// isolation — the same "one rule, offset per element" trick loader-pendulum-sync
// uses for its pendulum row, here on translateY/scaleY instead of rotation.
// ---------------------------------------------------------------------------

const CSS = `
.ns-lr-rack{
  display:flex;
  align-items:flex-end;
  justify-content:center;
  gap:calc(var(--ns-lr-bar) * .6);
  height:var(--ns-lr-height);
}
.ns-lr-bar{
  width:var(--ns-lr-bar);
  height:100%;
  border-radius:9999px;
  background:var(--foreground);
  transform-origin:bottom center;
  animation:ns-lr-pulse var(--ns-lr-period) infinite;
}
@keyframes ns-lr-pulse{
  0%,18%{transform:scaleY(.38);animation-timing-function:cubic-bezier(.16,1,.3,1)}
  30%{transform:scaleY(1.16);animation-timing-function:cubic-bezier(.34,1.56,.64,1)}
  38%{transform:scaleY(.88);animation-timing-function:cubic-bezier(.34,1.56,.64,1)}
  46%{transform:scaleY(1.05);animation-timing-function:cubic-bezier(.16,1,.3,1)}
  54%,100%{transform:scaleY(.38)}
}
@media (prefers-reduced-motion: reduce){
  .ns-lr-bar{animation:none;transform:scaleY(.7);opacity:.7;}
}
`;

export interface LathRackProps {
  /** number of laths, clamped 3-9 */
  count?: number;
  /** rack height in px */
  height?: number;
  /** ms for one full pulse to sweep across the rack and repeat */
  periodMs?: number;
  /** text announced via the component's own aria-live region */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function LathRack({
  count = 5,
  height = 40,
  periodMs = 2200,
  label = "Loading",
  className = "",
}: LathRackProps) {
  const n = Math.min(9, Math.max(3, Math.round(count)));
  const bar = Math.max(3, Math.round(height * 0.11));
  const step = periodMs * 0.085;

  return (
    <span
      role="status"
      aria-live="polite"
      data-loader-spring-bars
      className={`inline-flex items-end ${className}`}
      style={{
        height,
        ["--ns-lr-bar" as string]: `${bar}px`,
        ["--ns-lr-height" as string]: `${height}px`,
        ["--ns-lr-period" as string]: `${periodMs}ms`,
      }}
    >
      <style>{CSS}</style>
      <span className="ns-lr-rack" aria-hidden="true">
        {Array.from({ length: n }, (_, i) => (
          <span
            key={i}
            className="ns-lr-bar"
            style={{ animationDelay: `${-(i * step)}ms` }}
          />
        ))}
      </span>
      <span className="sr-only">{label}</span>
    </span>
  );
}
Build spec

A compact, always-on loading glyph (3-9 bars via a clamped `count` prop, sized via `height`) built as a row of rounded bars anchored at the bottom (`transform-origin:bottom center`) so they read as leaf springs mounted on a rail rather than a generic equalizer. Every bar runs the exact same `@keyframes` rule — rest compressed (scaleY .38) through a rise past its own resting height on ease-out-expo, a two-beat decaying overshoot on the house spring curve (cubic-bezier(.34,1.56,.64,1): stretched too far, corrects, small second bounce), back to rest — but each bar's `animation-delay` is a negative offset proportional to its index (~8.5% of `periodMs` per lath), so the identical waveform arrives at each bar slightly later than its left neighbor: the same 'one rule, phased per element' technique this registry's loader-pendulum-sync uses for its pendulum row, applied here to scaleY displacement instead of rotation. The visible result is one pulse of compression travelling left to right down the rack and looping, never N independent bars bouncing on their own clocks. Ink is `var(--foreground)` only, no gradients, no per-bar color. The whole glyph is a single `role="status" aria-live="polite"` element; the bar row is `aria-hidden` and a visually hidden text node (the `label` prop, default 'Loading') is the only thing announced — nothing here is interactive, so it renders zero controls and is correctly exempt from the tabbability check. `prefers-reduced-motion` removes the animation entirely, leaving every bar at a fixed mid-height with reduced opacity rather than either extreme of the pulse, so the resting frame reads as paused motion rather than an arbitrary freeze-frame. Props: count (3-9, default 5), height (px, default 40, bar width derives from it), periodMs (full pulse-sweep-and-repeat cycle, default 2200), label, className. Pure DOM + CSS, zero dependencies, no canvas, no framer-motion despite the source this was rebuilt from declaring it as a dependency it never actually imported.

Props

PropTypeDefaultDescription
count?number5number of laths, clamped 3-9
height?number40rack height in px
periodMs?number2200ms for one full pulse to sweep across the rack and repeat
label?string"Loading"text announced via the component's own aria-live region
className?stringextra classes merged onto the rendered root element