Skip to content
Brok UI

Loading…

No results

Real-Time Cursors

Open source

A simulated multiplayer-presence overlay: labeled cursors glide along smooth looping paths inside the container, mimicking collaborators editing live. Optionally one cursor follows the viewer's real pointer. Token-coloured and reduced-motion safe (static scatter).

Version
v1.0.4
Stability
stable
License
MIT
Related
Avatar Group
Status
Mockup

Preview

previews.components.real-time-cursors.default.blade.php Blade
<x-ui.real-time-cursors
    :follow="true"
    class="block h-64 w-full max-w-xl rounded-lg border border-border bg-card"
/>

Installation

terminal
php artisan ui:add real-time-cursors

Note

This component ships an Alpine behavior module at resources/js/ui/real-time-cursors.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './real-time-cursors.js';

Registry contract

php artisan ui:add real-time-cursors writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/real-time-cursors.blade.php
  • js resources/js/ui/real-time-cursors.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

real-time-cursors.md
# Brok UI: Real-Time Cursors (`real-time-cursors`)

A simulated multiplayer-presence overlay: labeled cursors glide along smooth looping paths inside the container, mimicking collaborators editing live. Optionally one cursor follows the viewer's real pointer. Token-coloured and reduced-motion safe (static scatter).

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add real-time-cursors
```

## Usage

```blade
<x-ui.real-time-cursors
    :follow="true"
    class="block h-64 w-full max-w-xl rounded-lg border border-border bg-card"
/>
```

## Props

- `users` (array, default `['Alex', 'Sam', 'Jordan', 'Riley']`) — Collaborator names rendered as drifting cursors; defaults to a neutral sample set.
- `follow` (bool, default `false`) — Makes the first cursor track the viewer's real pointer instead of wandering.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Simulating live multiplayer presence, such as remote collaborators' cursors, drifting over shared content for a marketing or product demo.
- Optionally letting the first cursor track the viewer's real pointer while the rest wander on their own paths.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Real collaborative cursors from a live multiplayer session are available; wire those positions in directly instead of the simulated wander.
- Showing who is present without simulating cursor movement; use avatar-group or status instead.

## Anti-patterns

- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus

## Rules

- Use the `<brok:real-time-cursors>` tag (or `<x-ui.real-time-cursors>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/real-time-cursors
- Registry JSON (files, props, contract): https://brokui.dev/r/open/real-time-cursors.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
users array ['Alex', 'Sam', 'Jordan', 'Riley'] Collaborator names rendered as drifting cursors; defaults to a neutral sample set.
follow bool false Makes the first cursor track the viewer's real pointer instead of wandering.

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

real-time-cursors real-time-cursors-cursor

Behavior

  • Each cursor drifts along a smooth looping path; when follow is true the first cursor instead tracks the viewer's real pointer inside the container.
  • Each cursor cycles through a small fixed set of token colors, with its name chip always readable on the card surface regardless of the arrow's color.
  • Reduced motion renders a static scatter of cursors instead of animating their paths.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.

Guidance

Expressive and showcase effect

Support brand expression or a showcase moment.

Use when

  • Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
  • Simulating live multiplayer presence, such as remote collaborators' cursors, drifting over shared content for a marketing or product demo.
  • Optionally letting the first cursor track the viewer's real pointer while the rest wander on their own paths.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • Real collaborative cursors from a live multiplayer session are available; wire those positions in directly instead of the simulated wander.
  • Showing who is present without simulating cursor movement; use avatar-group or status instead.

Use instead

  • Static content-first presentation

Anti-patterns

  • Heavy effects in forms or task-dense workflows
  • Motion that interferes with reading or focus
Anatomy
root real-time-cursors-cursor
Theming hooks
real-time-cursors real-time-cursors-cursor

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The cursor overlay is aria-hidden and never receives pointer events, so it cannot block or confuse interaction with the host content underneath.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="real-time-cursors-{{ $record->id }}">
    <x-ui.real-time-cursors
        :follow="true"
        class="block h-64 w-full max-w-xl rounded-lg border border-border bg-card"
    />
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/real-time-cursors.blade.php Blade
@props([
    // Each entry is a collaborator name; the arrow + chip is coloured by cycling
    // a fixed token set below. Neutral defaults — no real PII.
    'users' => ['Alex', 'Sam', 'Jordan', 'Riley'],
    // When true the FIRST cursor tracks the viewer's real pointer inside the box;
    // the remaining cursors roam on their own wandering paths.
    'follow' => false,
])

@php
    // A decorative presence effect: several labeled cursors (an SVG arrow + a name
    // chip) drift around the container along smooth looping paths, as if remote
    // collaborators were editing live. All motion + the optional pointer-follow
    // live in real-time-cursors.js; reduced-motion users get a static scatter.
    //
    // COLOUR is token-only. We cycle each cursor through a SMALL FIXED SET of
    // text-* token classes. The classes are written here as contiguous literals so
    // Tailwind's JIT can see them — they are NEVER concatenated/spliced in JS.
    // The SVG arrow fills `currentColor` (so it picks up the wrapper's text token);
    // the name chip uses the card surface (bg-card/text-card-foreground/border) so
    // it always stays readable while the arrow shows the per-user colour.
    $palette = [
        'text-primary',
        'text-secondary-foreground',
        'text-accent-foreground',
        'text-destructive',
        'text-muted-foreground',
    ];

    // Normalise users to a list of plain string labels.
    $users = empty($users) ? ['Alex', 'Sam', 'Jordan', 'Riley'] : array_values($users);
    $labels = array_map(
        fn ($u) => is_array($u) ? ($u['name'] ?? '') : (string) $u,
        $users
    );

    // Config handed to Alpine: how many cursors, and whether the first follows the
    // real pointer. Per-cursor seed values keep each path distinct yet stable.
    $config = [
        'count' => count($labels),
        'follow' => (bool) $follow,
    ];
@endphp

<div
    data-slot="real-time-cursors"
    x-data="uiRealTimeCursors(@js($config))"
    x-ref="root"
    {{ $attributes->merge(['class' => 'relative isolate overflow-hidden rounded-lg border border-border bg-background']) }}
>
    {{-- Host content (the "document" the collaborators are editing) renders below
         the cursor layer. --}}
    @if ($slot->isNotEmpty())
        <div class="relative z-0">{{ $slot }}</div>
    @endif

    {{-- Cursor overlay. Purely decorative: it never receives pointer events and is
         hidden from assistive tech. --}}
    <div
        class="pointer-events-none absolute inset-0 z-10"
        aria-hidden="true"
    >
        @foreach ($labels as $i => $label)
            @php
                // Fixed, literal token class per cursor (JIT-visible — see $palette).
                $colorClass = $palette[$i % count($palette)];
            @endphp
            <div
                data-slot="real-time-cursors-cursor"
                data-index="{{ $i }}"
                class="absolute start-0 top-0 will-change-transform {{ $colorClass }}"
                {{-- Alpine builds a transform-only style: translate(Xpx, Ypx). The
                     x-ref array lets the JS read/measure this node by index. --}}
                x-ref="cursor"
                :style="cursorStyle({{ $i }})"
            >
                {{-- Classic arrow pointer (~20px). Fills `currentColor`, so it takes
                     the per-user token colour from the wrapper above. The thin
                     stroke uses the background token so the tip reads on any
                     surface. --}}
                <svg
                    width="20"
                    height="20"
                    viewBox="0 0 24 24"
                    fill="currentColor"
                    class="block drop-shadow-sm"
                    aria-hidden="true"
                >
                    <path
                        d="M5 3l13 7-5.5 1.5L9.5 17 5 3z"
                        stroke="currentColor"
                        stroke-width="0.5"
                        stroke-linejoin="round"
                    />
                </svg>

                {{-- Name chip, offset to the arrow's lower-right. Token surface so
                     it stays readable; a left swatch shows the cursor colour. --}}
                <span
                    class="absolute start-4 top-4 inline-flex select-none items-center gap-1 whitespace-nowrap rounded-md border border-border bg-card px-2 py-0.5 text-xs font-medium text-card-foreground shadow-sm"
                >
                    <span class="size-2 rounded-full bg-current"></span>
                    {{ $label }}
                </span>
            </div>
        @endforeach
    </div>
</div>
resources/js/ui/real-time-cursors.js JS
/**
 * Real-Time Cursors behavior (simulated multiplayer-presence overlay).
 *
 * Several labeled cursors drift around inside the host box along smooth, looping
 * wandering paths — each eases toward a randomly re-chosen target point and picks
 * a fresh target on arrival, so it meanders organically and never leaves the box.
 * When `follow` is true the FIRST cursor instead tracks the viewer's real pointer
 * (clamped inside the box) while the rest keep roaming.
 *
 * Rendering is transform-only: each frame we write `translate(Xpx, Ypx)` per
 * cursor via the reactive `_pos` array that `cursorStyle(i)` reads in Blade. The
 * cursors are decorative (the layer is pointer-events-none + aria-hidden in the
 * markup); colour comes entirely from the per-cursor token class in Blade.
 *
 * Reduced motion: cursors are placed at a static scattered set of positions, with
 * NO rAF loop and NO pointer-follow listener.
 *
 * Cleanup: destroy() cancels the rAF and removes the optional pointermove
 * listener so unmounting mid-interaction cannot leak.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiRealTimeCursors', (config = {}) => ({
        count: Math.max(0, config.count ?? 0),
        follow: config.follow ?? false,

        // Reactive per-cursor positions (px, relative to the box top-left). Blade's
        // cursorStyle(i) reads this to build the transform.
        _pos: [],

        // Internal per-cursor wander state (current point, target point, speed,
        // and a small inset margin so the chip stays inside the box).
        _agents: [],
        _w: 0,
        _h: 0,
        _raf: null,
        _ro: null,
        _onPointerMove: null,
        _reduced: false,
        // Latest real-pointer position (box-relative) for the follow cursor.
        _pointer: { x: 0, y: 0, has: false },

        init() {
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            this.measure();
            this.seed();

            if (this._reduced) {
                // Static scatter: place each cursor at its seeded point, no motion.
                this.commit();
                return;
            }

            if (this.follow) {
                this._onPointerMove = (e) => this.trackPointer(e);
                window.addEventListener('pointermove', this._onPointerMove, { passive: true });
            }

            // Re-measure on resize so targets stay inside the box; debounced to a
            // single frame so a drag-resize doesn't thrash.
            let pending = false;
            this._ro = new ResizeObserver(() => {
                if (pending) return;
                pending = true;
                requestAnimationFrame(() => {
                    pending = false;
                    this.measure();
                });
            });
            this._ro.observe(this.$refs.root);

            this._raf = requestAnimationFrame(() => this.loop());
        },

        // Style string consumed by Blade's :style="cursorStyle(i)". Transform only.
        cursorStyle(i) {
            const p = this._pos[i] || { x: 0, y: 0 };
            return `transform: translate(${p.x}px, ${p.y}px)`;
        },

        // Box size in CSS px. A ~28px inset keeps the arrow + chip inside the edges.
        measure() {
            const root = this.$refs.root;
            this._w = Math.max(1, root.clientWidth || 1);
            this._h = Math.max(1, root.clientHeight || 1);
        },

        inset() {
            return 28;
        },

        // A random point inside the safe (inset) region of the box.
        randomTarget() {
            const m = this.inset();
            return {
                x: m + Math.random() * Math.max(1, this._w - m * 2),
                y: m + Math.random() * Math.max(1, this._h - m * 2),
            };
        },

        seed() {
            this._agents = [];
            this._pos = [];
            for (let i = 0; i < this.count; i++) {
                const start = this.randomTarget();
                this._agents.push({
                    x: start.x,
                    y: start.y,
                    target: this.randomTarget(),
                    // Per-cursor ease factor (lower = slower drift) for variety.
                    ease: 0.012 + Math.random() * 0.02,
                });
                this._pos.push({ x: start.x, y: start.y });
            }
        },

        // Mirror agent positions into the reactive _pos array (triggers re-render).
        commit() {
            for (let i = 0; i < this._agents.length; i++) {
                const a = this._agents[i];
                this._pos[i] = { x: Math.round(a.x), y: Math.round(a.y) };
            }
        },

        // Real-pointer position relative to the box, clamped to the inset region.
        trackPointer(e) {
            const rect = this.$refs.root.getBoundingClientRect();
            const m = this.inset();
            const x = e.clientX - rect.left;
            const y = e.clientY - rect.top;
            const inside = x >= 0 && x <= this._w && y >= 0 && y <= this._h;
            if (!inside) {
                this._pointer.has = false;
                return;
            }
            this._pointer.x = Math.min(this._w - m, Math.max(m, x));
            this._pointer.y = Math.min(this._h - m, Math.max(m, y));
            this._pointer.has = true;
        },

        loop() {
            for (let i = 0; i < this._agents.length; i++) {
                const a = this._agents[i];

                if (this.follow && i === 0 && this._pointer.has) {
                    // Follow cursor: ease toward the real pointer.
                    a.x += (this._pointer.x - a.x) * 0.2;
                    a.y += (this._pointer.y - a.y) * 0.2;
                } else {
                    // Roaming cursor: lerp toward the current target; on arrival,
                    // pick a fresh random target so the path loops organically.
                    a.x += (a.target.x - a.x) * a.ease;
                    a.y += (a.target.y - a.y) * a.ease;
                    const dx = a.target.x - a.x;
                    const dy = a.target.y - a.y;
                    if (dx * dx + dy * dy < 4) {
                        a.target = this.randomTarget();
                    }
                }
            }

            this.commit();
            this._raf = requestAnimationFrame(() => this.loop());
        },

        destroy() {
            if (this._raf) {
                cancelAnimationFrame(this._raf);
                this._raf = null;
            }
            if (this._ro) {
                this._ro.disconnect();
                this._ro = null;
            }
            if (this._onPointerMove) {
                window.removeEventListener('pointermove', this._onPointerMove);
                this._onPointerMove = null;
            }
            this._agents = [];
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.4
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0