Bottom Activity Strip
Slim horizontally-scrolling row of recent canvas events for low-noise live activity.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/bottom-activity-strip.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook4 stories available:
Code
"use client";
import {
type ComponentPropsWithoutRef,
forwardRef,
type ReactNode,
} from "react";
import { cn } from "../../lib/utils";
/**
* Tone of an event — drives the leading dot color.
*
* @public
*/
export type ActivityStripTone =
| "danger"
| "info"
| "neutral"
| "success"
| "warn";
const TONE_DOT: Record<ActivityStripTone, string> = {
danger: "bg-red-500",
info: "bg-blue-500",
neutral: "bg-muted-foreground",
success: "bg-emerald-500",
warn: "bg-amber-500",
};
/**
* One event in the strip.
*
* @public
*/
export type ActivityEvent = {
/** Stable identifier — used as the React key + analytics hook. */
id: string;
/** Short label (e.g. `"deploy completed"`). */
label: ReactNode;
/** Optional click handler — when provided, the chip becomes a button. */
onActivate?: () => void;
/** Optional tone for the leading dot. Defaults to `"neutral"`. */
tone?: ActivityStripTone;
/** Pre-formatted timestamp (host owns formatting). */
ts: ReactNode;
};
/**
* Localizable strings.
*
* @public
*/
export type BottomActivityStripLabels = {
/** Empty-state copy. Defaults to `"No recent activity"`. */
empty?: string;
/** Aria-label for the strip. Defaults to `"Recent activity"`. */
region?: string;
};
const DEFAULT_LABELS = {
empty: "No recent activity",
region: "Recent activity",
} as const satisfies Required<BottomActivityStripLabels>;
/**
* Props for {@link BottomActivityStrip}.
*
* @public
*/
export type BottomActivityStripProps = {
/** Event entries — newest first. */
events: ActivityEvent[];
/** Localizable strings. */
labels?: BottomActivityStripLabels;
/** Cap the rendered events. The component drops the tail without warning. */
maxEvents?: number;
} & ComponentPropsWithoutRef<"section">;
const ChipBody = (props: { event: ActivityEvent }): React.ReactElement => {
const { event } = props;
const tone = event.tone ?? "neutral";
return (
<span className="flex items-center gap-1.5 whitespace-nowrap">
<span
aria-hidden="true"
className={cn("size-1.5 rounded-full", TONE_DOT[tone])}
/>
<span className="text-foreground">{event.label}</span>
<span className="text-[10px] text-muted-foreground" data-strip-event-ts>
{event.ts}
</span>
</span>
);
};
const Chip = (props: { event: ActivityEvent }): React.ReactElement => {
const { event } = props;
const tone = event.tone ?? "neutral";
if (event.onActivate) {
const handleClick = (): void => {
event.onActivate?.();
};
return (
<button
className="flex items-center rounded-full border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-strip-event={event.id}
data-strip-event-tone={tone}
onClick={handleClick}
type="button"
>
<ChipBody event={event} />
</button>
);
}
return (
<span
className="flex items-center rounded-full border border-border bg-background px-2 py-1 text-[11px]"
data-strip-event={event.id}
data-strip-event-tone={tone}
>
<ChipBody event={event} />
</span>
);
};
/**
* Slim bottom strip showing a horizontally-scrolling row of recent
* canvas events. The lowest-noise live execution surface — keep
* `ObjectCard`, panels, and overlays for high-value surfaces; let the
* strip carry the steady drip of activity.
*
* Pure presentation; the host computes the event list (newest first)
* and supplies an optional `onActivate` per event to jump to the
* related object.
*
* Distinct from `ActivityLog` (vertical, persistent) and `LiveFeed`
* (full-height feed): this primitive is a single horizontal row that
* lives at the bottom of the canvas.
*
* @example
* ```tsx
* <BottomActivityStrip
* events={[
* { id: "1", label: "deploy ok", ts: "12s", tone: "success" },
* { id: "2", label: "queue spike", ts: "1m", tone: "warn" },
* ]}
* maxEvents={20}
* />
* ```
*
* @public
*/
export const BottomActivityStrip = forwardRef<
HTMLElement,
BottomActivityStripProps
>((props, ref) => {
const { className, events, labels, maxEvents, ...rest } = props;
const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
const visible =
maxEvents === undefined || maxEvents >= events.length
? events
: events.slice(0, maxEvents);
return (
<section
aria-label={resolvedLabels.region}
className={cn(
"flex w-full items-center gap-2 overflow-x-auto rounded-md border border-border bg-background/90 px-2 py-1 text-foreground",
className,
)}
data-bottom-activity-strip
ref={ref}
{...rest}
>
{visible.length === 0 ? (
<span
className="px-2 text-[11px] text-muted-foreground"
data-strip-state="empty"
>
{resolvedLabels.empty}
</span>
) : (
visible.map((event) => <Chip event={event} key={event.id} />)
)}
</section>
);
});
BottomActivityStrip.displayName = "BottomActivityStrip";
typescript