- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Preparing your response
Gathering details
Ready when you are
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>
);
} On this page