Live Cursor

Remote user's cursor rendered at canvas coordinates with name + status chip.

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/live-cursor.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";

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

const DEFAULT_LABELS = {
  region: "Live cursor",
} as const satisfies Required<LiveCursorLabels>;

/**
 * Props for {@link LiveCursor}.
 *
 * @public
 */
export type LiveCursorProps = {
  /** Tailwind / arbitrary CSS color used for the pointer + name chip. Defaults to `var(--foreground)`. */
  color?: string;
  /** Localizable strings. */
  labels?: LiveCursorLabels;
  /** Display name shown in the chip. Pass `null` to hide the chip. */
  name?: ReactNode;
  /** Optional secondary line in the chip (e.g. status, role). */
  status?: ReactNode;
  /** Cursor X in canvas pixels. */
  x: number;
  /** Cursor Y in canvas pixels. */
  y: number;
} & ComponentPropsWithoutRef<"div">;

/**
 * Remote user's cursor rendered at canvas coordinates with an optional
 * name + status chip. Pure presentation; the host owns the websocket
 * stream + maps user ids to colors.
 *
 * The wrapper is `pointer-events: none` so host gestures pass through.
 *
 * @example
 * ```tsx
 * <div className="relative h-screen w-screen">
 *   <Canvas />
 *   <LiveCursor x={420} y={180} name="Bea" color="#5b8def" />
 * </div>
 * ```
 *
 * @public
 */
export const LiveCursor = forwardRef<HTMLDivElement, LiveCursorProps>(
  (props, ref) => {
    const { className, color, labels, name, status, x, y, ...rest } = props;
    const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
    const resolvedColor = color ?? "var(--foreground)";
    return (
      <div
        aria-label={
          typeof name === "string"
            ? `${resolvedLabels.region}: ${name}`
            : resolvedLabels.region
        }
        className={cn(
          "pointer-events-none absolute z-30 flex items-start gap-1",
          className,
        )}
        data-live-cursor
        ref={ref}
        role="img"
        style={{ left: x, top: y }}
        {...rest}
      >
        <svg
          aria-hidden="true"
          className="-ml-1 -mt-1 drop-shadow-sm"
          data-live-cursor-pointer
          fill={resolvedColor}
          height={18}
          viewBox="0 0 16 18"
          width={16}
        >
          <path d="M0 0 L0 14 L4 11 L7 17 L10 16 L7 10 L13 10 Z" />
        </svg>
        {name === null || name === undefined ? null : (
          <span
            className="ml-2 mt-2 inline-flex flex-col rounded-md px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm"
            data-live-cursor-chip
            style={{ backgroundColor: resolvedColor }}
          >
            <span>{name}</span>
            {status ? (
              <span className="text-[9px] opacity-80" data-live-cursor-status>
                {status}
              </span>
            ) : null}
          </span>
        )}
      </div>
    );
  },
);
LiveCursor.displayName = "LiveCursor";
typescript

Dependencies

  • @vllnt/ui@^0.2.1