Playback Ghost

Translucent overlay marking where a canvas object was at a previous timestamp during playback.

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/playback-ghost.json
bash

Storybook

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

View in Storybook

4 stories available:

Code

"use client";

import {
  type ComponentPropsWithoutRef,
  forwardRef,
  type ReactNode,
} from "react";

import { cn } from "../../lib/utils";

/**
 * Object kind — drives the ghost glyph.
 *
 * @public
 */
export type PlaybackGhostKind =
  | "agent"
  | "artifact"
  | "input"
  | "output"
  | "run"
  | "task";

const KIND_GLYPH: Record<PlaybackGhostKind, string> = {
  agent: "◇",
  artifact: "◌",
  input: "↘",
  output: "↗",
  run: "▶",
  task: "▢",
};

const KIND_LABEL: Record<PlaybackGhostKind, string> = {
  agent: "Agent",
  artifact: "Artifact",
  input: "Input",
  output: "Output",
  run: "Run",
  task: "Task",
};

/**
 * Localizable strings.
 *
 * @public
 */
export type PlaybackGhostLabels = {
  /** Aria-label override. Defaults to `"Playback ghost"`. */
  region?: string;
};

const DEFAULT_LABELS = {
  region: "Playback ghost",
} as const satisfies Required<PlaybackGhostLabels>;

/**
 * Props for {@link PlaybackGhost}.
 *
 * @public
 */
export type PlaybackGhostProps = {
  /** Optional kind glyph + tooltip. */
  kind?: PlaybackGhostKind;
  /** Optional label rendered next to the glyph (e.g. id, run name). */
  label?: ReactNode;
  /** Localizable strings. */
  labels?: PlaybackGhostLabels;
  /** Ghost opacity `0..1`. Defaults to `0.4`. */
  opacity?: number;
  /** Ghost size in pixels. Defaults to `40`. */
  size?: number;
  /** Center X in canvas pixels. */
  x: number;
  /** Center Y in canvas pixels. */
  y: number;
} & ComponentPropsWithoutRef<"div">;

const clamp01 = (v: number): number => {
  if (v < 0) {
    return 0;
  }
  if (v > 1) {
    return 1;
  }
  return v;
};

/**
 * Translucent overlay marking where a canvas object was at a previous
 * timestamp during state playback. Renders a kind glyph (and optional
 * label) at the historical position so the user can compare the
 * present canvas against earlier state without losing context.
 *
 * Pure presentation; the host computes the historical position from the
 * scrubbed timestamp. The wrapper is `pointer-events: none` so host
 * gestures pass through.
 *
 * @example
 * ```tsx
 * <div className="relative h-screen w-screen">
 *   <Canvas />
 *   <PlaybackGhost x={420} y={180} kind="run" label="research-2025" />
 * </div>
 * ```
 *
 * @public
 */
export const PlaybackGhost = forwardRef<HTMLDivElement, PlaybackGhostProps>(
  (props, ref) => {
    const {
      className,
      kind,
      label,
      labels,
      opacity = 0.4,
      size = 40,
      x,
      y,
      ...rest
    } = props;
    const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
    const safeOpacity = clamp01(opacity);
    const safeSize = size < 16 ? 16 : size;
    const ariaLabel = kind
      ? `${resolvedLabels.region}: ${KIND_LABEL[kind]}`
      : resolvedLabels.region;
    return (
      <div
        aria-label={ariaLabel}
        className={cn(
          "pointer-events-none absolute z-10 inline-flex -translate-x-1/2 -translate-y-1/2 items-center justify-center gap-1.5 rounded-md border border-dashed border-border/70 bg-background/40 px-2 py-1 text-xs text-foreground backdrop-blur-sm",
          className,
        )}
        data-playback-ghost
        data-playback-kind={kind ?? "unknown"}
        ref={ref}
        role="img"
        style={{
          left: x,
          minHeight: safeSize,
          minWidth: safeSize,
          opacity: safeOpacity,
          top: y,
        }}
        {...rest}
      >
        {kind ? (
          <span aria-hidden="true" className="text-muted-foreground">
            {KIND_GLYPH[kind]}
          </span>
        ) : null}
        {label ? (
          <span className="truncate" data-playback-ghost-label>
            {label}
          </span>
        ) : null}
      </div>
    );
  },
);
PlaybackGhost.displayName = "PlaybackGhost";
typescript

Dependencies

  • @vllnt/ui@^0.2.1