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).
Preview
<x-ui.real-time-cursors
:follow="true"
class="block h-64 w-full max-w-xl rounded-lg border border-border bg-card"
/>
Installation
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:
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.
-
resources/views/components/ui/real-time-cursors.blade.php -
resources/js/ui/real-time-cursors.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
@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>
/**
* 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