For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Composer Loader

An animated light band for a composer or other loading surface.

Installation

$ pnpm dlx shadcn@latest add @cobalt/composer-loader

Source

"use client";

import { cn } from "cn";
import { useEffect, useId, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import type * as React from "react";

/**
 * Composer Loader — a loading state that wraps a chat composer while the agent
 * works: an iridescent light band orbiting the pill's rim with a soft bloom
 * bleeding inward.
 *
 * The band is dash segments on an SVG stroke of the pill shape
 * (pathLength-normalized, so it travels at constant speed and bends around
 * the end caps), drawn at three scales — crisp line, tight glow, wide bloom —
 * with the sharp line leading and every soft layer's head pulled back behind
 * the tip.
 *
 * The component paints the pill surface itself and layers the light above
 * it, so the wrapped composer must not paint its own background:
 *
 *   <ComposerLoader active={isWaitingForReply}>
 *     <Composer className="bg-transparent shadow-none" />
 *   </ComposerLoader>
 *
 * `active` fades the whole effect in and out (450ms), so toggling it on
 * send / reply reads as the composer lighting up rather than snapping.
 */

export interface ComposerLoaderProps extends Omit<
  React.ComponentProps<"div">,
  "children"
> {
  children?: ReactNode;
  /** Show the light. Fades in/out — flip it while awaiting a response. */
  active?: boolean;
  /** Four gradient colors, spread across the pill left → right. */
  colors?: [string, string, string, string];
  /** Seconds per full lap. */
  speed?: number;
  /** Overall light opacity. */
  intensity?: number;
  /** How far the bloom bleeds inward from the rim, px. */
  bloom?: number;
  /** Bloom layer opacity. */
  bloomStrength?: number;
  /** How much of the perimeter the band occupies, degrees (of 360). */
  arc?: number;
  /** Reverse the travel direction. */
  reverse?: boolean;
  /** Corner radius, px. Defaults to a full pill. */
  radius?: number;
  /** Width of the crisp line, px; the tight glow scales with it. */
  line?: number;
  /** Draw the wide bloom alone, no line or tight glow: a soft wash for a beam to lead. */
  bloomOnly?: boolean;
  /** Paint the pill surface behind the light (on by default). */
  surface?: boolean;
  /**
   * Fade the band's two ends instead of cutting them, 0–1: the fraction of
   * the band's length that ramps. Each layer is stacked as shorter, centred
   * copies at a fraction of its opacity; the blur melts the steps together.
   */
  taper?: number;
  /** How the light composites over what's beneath, e.g. `screen` to read as light on a surface. */
  blend?: CSSProperties["mixBlendMode"];
  /**
   * Moves the band forward along the lap, as a fraction of the perimeter
   * (0–1). Lets two loaders on one pill line up: a short beam with
   * `offset` equal to the difference of the two arcs (as fractions) leads
   * a wider glow tip to tip.
   */
  offset?: number;
  className?: string;
}

const DEFAULT_COLORS: [string, string, string, string] = [
  "var(--composer-loader-color-1, var(--chart-1))",
  "var(--composer-loader-color-2, var(--chart-4))",
  "var(--composer-loader-color-3, var(--chart-3))",
  "var(--composer-loader-color-4, var(--chart-1))",
];

export function ComposerLoader({
  children,
  active = true,
  colors = DEFAULT_COLORS,
  speed = 4.5,
  intensity = 0.7,
  bloom = 16,
  bloomStrength = 0.3,
  arc = 120,
  reverse = false,
  radius,
  line = 2.5,
  bloomOnly = false,
  surface,
  taper = 0,
  blend,
  offset = 0,
  className,
  ...props
}: ComposerLoaderProps) {
  const gradientId = `composer-loader-${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
  const clipRef = useRef<HTMLSpanElement>(null);
  const [box, setBox] = useState({ w: 640, h: 52 });

  useEffect(() => {
    const el = clipRef.current;
    if (!el) {
      return;
    }
    const observer = new ResizeObserver(() =>
      setBox({
        w: Math.max(1, el.clientWidth),
        h: Math.max(1, el.clientHeight),
      })
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const [c0, c1, c2, c3] = colors;
  // Gradient center: pure blend of the middle colors, so the band never
  // washes out to white.
  const mid = `color-mix(in srgb, ${c1} 50%, ${c2})`;

  const band = (arc / 360) * 100;
  const rx = radius ?? box.h / 2;
  const cornerRadius = radius ?? 9999;
  const direction = reverse ? "reverse" : "normal";

  // Path units per px, for pulling each soft layer's head behind the tip.
  // Rounded-rect perimeter: straight runs plus the four corner arcs (for the
  // default pill, rx = h/2 and this reduces to 2(w−h) + πh).
  const cornerR = Math.min(rx, box.w / 2, box.h / 2);
  const perimeter = Math.max(
    1,
    2 * (box.w + box.h) - 8 * cornerR + 2 * Math.PI * cornerR
  );
  const pxUnits = (px: number) => (px * 100) / perimeter;
  // A layer's delay is its phase: the larger the phase, the further BACK
  // along the lap the band sits (a negative delay is a head start). So
  // moving a band forward, `offset` included, means subtracting.
  const dashPhase = (len: number, backPx: number) =>
    (reverse ? pxUnits(backPx) : len - band + pxUnits(backPx)) - offset * 100;

  // Tapering stacks each layer as shorter, centred copies: the ramp at each
  // end is a staircase with one step per copy, so it takes many copies and
  // a little blur on the crisp line for the steps to melt into a gradient.
  const TAPER_STEPS = 14;
  const CRISP_BLUR = taper > 0 ? Math.max(0.5, line * 0.8) : 0.5;
  const stroke = (
    width: number,
    blur: number,
    opacity: number,
    dashLen: number,
    backPx: number
  ) => {
    const copies = taper > 0 ? TAPER_STEPS : 1;
    return Array.from({ length: copies }, (_, index) => {
      // Copy 0 is the full band; each next one is shorter and moved forward
      // by half the difference, so all of them share one midpoint and the
      // ends thin out step by step.
      const len = dashLen * (1 - (taper * index) / copies);
      const phase = dashPhase(dashLen, backPx) - (dashLen - len) / 2;
      return (
        <rect
          key={index}
          x={0}
          y={0}
          width={box.w}
          height={box.h}
          rx={rx}
          pathLength={100}
          fill="none"
          stroke={`url(#${gradientId})`}
          strokeWidth={width}
          strokeLinecap="round"
          strokeDasharray={`${len} ${100 - len}`}
          className="motion-reduce:animate-none!"
          // SVG-native blur, not CSS filter: WebKit (iOS Safari and every iPhone
          // browser) ignores CSS filters on individual SVG elements, which
          // rendered the whole light razor-sharp on phones.
          filter={
            blur > 0
              ? `url(#${gradientId}-b${String(blur).replace(".", "_")})`
              : undefined
          }
          style={{
            opacity: opacity / copies,
            animation: `composer-loader-dash ${speed}s linear ${(phase * speed) / 100}s infinite ${direction}`,
          }}
        />
      );
    });
  };

  /** One feGaussianBlur per blur radius the layers use. The region is
   *  widened well past the stroke so the blur never clips at the filter
   *  bounds. */
  const blurFilter = (radius: number) => (
    <filter
      key={radius}
      id={`${gradientId}-b${String(radius).replace(".", "_")}`}
      x="-50%"
      y="-50%"
      width="200%"
      height="200%"
    >
      <feGaussianBlur stdDeviation={radius} />
    </filter>
  );

  return (
    <div
      data-slot="composer-loader"
      className={cn(
        children ? "relative" : "pointer-events-none absolute inset-0",
        className
      )}
      {...props}
    >
      {(surface ?? !!children) && (
        <span
          aria-hidden
          className="absolute inset-0 bg-[var(--surface-raised,var(--popover))] shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))]"
          style={{ borderRadius: cornerRadius }}
        />
      )}

      <span
        ref={clipRef}
        aria-hidden
        className="pointer-events-none absolute inset-0 overflow-hidden transition-opacity duration-[450ms] ease-[ease] motion-reduce:transition-none"
        style={{
          borderRadius: cornerRadius,
          opacity: active ? 1 : 0,
          mixBlendMode: blend,
        }}
      >
        <svg
          width="100%"
          height="100%"
          viewBox={`0 0 ${box.w} ${box.h}`}
          preserveAspectRatio="none"
          style={{ opacity: intensity }}
        >
          <defs>
            <linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="0">
              <stop offset="0%" stopColor={c0} />
              <stop offset="30%" stopColor={c1} />
              <stop offset="50%" stopColor={mid} />
              <stop offset="70%" stopColor={c2} />
              <stop offset="100%" stopColor={c3} />
            </linearGradient>
            {[14, 6, CRISP_BLUR].map(blurFilter)}
          </defs>
          {/* wide bloom bleeding inward (outer half clipped by the pill) */}
          {bloom > 0 &&
            stroke(bloom * 2, 14, bloomStrength, band * 0.9, bloom + 16)}
          {/* tight glow */}
          {!bloomOnly && stroke(line * 3.2, 6, 0.8, band * 0.95, 10)}
          {/* crisp refraction line, tip leading */}
          {!bloomOnly && stroke(line, CRISP_BLUR, 1, band, 0)}
        </svg>
      </span>

      {children && <div className="relative">{children}</div>}
    </div>
  );
}