Bottom Activity Strip

Slim horizontally-scrolling row of recent canvas events for low-noise live activity.

Report a bug

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.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

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

Dependencies

  • @vllnt/ui@^0.2.1