Skip to content

Cursors

playhtml can track cursors out of the box. When enabled, visitors see each other’s cursors live, along with their colors and names.

import { playhtml } from "playhtml";

playhtml.init({
  cursors: {
    enabled: true,
  }
});

Controls which users see each other’s cursors. By default, cursors are scoped to the current page.

Type: "page" | "domain" | "section" | (context) => string

// Page-specific (default)
cursors: {
  enabled: true,
  room: "page"  // Only users on /blog/post see each other
}

// Domain-wide
cursors: {
  enabled: true,
  room: "domain"  // All users across yoursite.com see each other
}

// Section-wide
cursors: {
  enabled: true,
  room: "section"  // All users in /blog/* see each other
}

// Custom function
cursors: {
  enabled: true,
  room: ({ domain, pathname, search }) => {
    // Custom logic
    if (pathname.startsWith('/workspace/')) {
      return `${domain}-workspace`;
    }
    return `${domain}${pathname}`;
  }
}

Filter which cursors are visible. Useful for showing domain-wide presence while only rendering same-page cursors.

Type: (presence: CursorPresence) => boolean

cursors: {
  enabled: true,
  room: "domain",  // Connect everyone
  shouldRenderCursor: (presence) => {
    // Only render cursors from the same page
    return presence.page === window.location.pathname;
  }
}

Presence data includes:

  • presence.page - The page path the cursor is on
  • presence.cursor - Current cursor position { x, y, pointer }, or null when the user is present but not publishing a cursor position
  • presence.playerIdentity - User info { name, playerStyle: { colorPalette } }
  • presence.message - Chat message (if chat enabled)
  • presence.lastSeen - Timestamp

Customize cursor appearance based on presence data.

Type: (presence: CursorPresence) => Partial<CSSStyleDeclaration> | Record<string, string>

cursors: {
  enabled: true,
  room: "domain",
  getCursorStyle: (presence) => {
    // Fade cursors from other pages
    if (presence.page !== window.location.pathname) {
      return {
        opacity: '0.4',
        filter: 'blur(3px)'
      };
    }
    return {};
  }
}

Example: Distance-based styling

getCursorStyle: (presence) => {
  if (!presence.cursor) return {};

  const distance = Math.sqrt(
    Math.pow(presence.cursor.x - myX, 2) +
    Math.pow(presence.cursor.y - myY, 2)
  );

  if (distance > 500) {
    return { opacity: '0.3' };
  }
  return {};
}
cursors: {
  enabled: true,

  // Custom player identity
  playerIdentity: {
    name: "Alice",
    playerStyle: {
      colorPalette: ["#3b82f6", "#8b5cf6", "#ec4899"]
    }
  },

  // Proximity detection
  proximityThreshold: 150,  // pixels
  onProximityEntered: (playerIdentity, positions, angle) => {
    console.log("User nearby!", playerIdentity.name);
  },
  onProximityLeft: (connectionId) => {
    console.log("User left proximity");
  },

  // Visibility threshold (hide distant cursors)
  visibilityThreshold: 1000,  // pixels

  // Enable chat
  enableChat: true,

  // Coordinate space cursors are stored and broadcast in
  coordinateMode: "relative",  // "relative" (default) or "absolute"

  // CSS cursor applied to the page while cursors are active
  cursorStyle: "none",

  // Custom cursor rendering
  onCustomCursorRender: (connectionId, element) => {
    // Return custom element or null for default
    return null;
  }
}

coordinateMode: how cursor positions are stored and shared:

  • "relative" (default): positions are viewport percentages, so a cursor lands on the same relative spot for every viewer regardless of window size. Best for documents and most pages.
  • "absolute": positions are document pixels. Use this when collaborators share an identical fixed-size layout and you want pixel-for-pixel agreement.

For a pannable / zoomable canvas, you usually don’t set coordinateMode directly. Pass a transformed container instead, which stores cursors in that container’s local space.

cursorStyle: a CSS cursor value applied to the page while cursors are enabled (e.g. "none" to hide the native pointer in favor of the rendered one).

Where playhtml mounts cursor DOM (and the cursor stylesheet). Defaults to document.body. Two reasons to set it:

  1. Surviving SPA navigation. If your framework swaps document.body on route changes (Astro ViewTransitions, htmx boost, Turbo), pass a container you mark as persistent so cursors aren’t destroyed. See navigation.
  2. Anchoring cursors to a pannable / zoomable canvas. If the container has its own CSS transform (e.g. you implement pinch-zoom and pan by setting transform: translate(...) scale(...) on a wrapper), playhtml stores cursor coordinates in that container’s local space, so two viewers with different pan/zoom agree on which content a cursor is hovering. See the next section.

Type: HTMLElement | string | (() => HTMLElement | null) | React.RefObject<HTMLElement>

If your page implements its own pan and zoom by applying a CSS transform to a wrapper element, the default cursor behavior doesn’t do what you want: cursors are stored in viewport pixels, so when one user pans, their cursor for everyone else lands at the wrong word / shape / cell. The fix is one option:

playhtml.init({
  cursors: {
    enabled: true,
    container: ".canvas",  // your transformed wrapper
  },
});

When container resolves to a non-document.body element, playhtml reads its live transform matrix from getComputedStyle() on every pointer event:

  • Storage is the container’s local (pre-transform) coordinate space. Two clients with different pan/zoom now agree on cursor positions.
  • Rendering mounts cursors inside the container with position: absolute. The container’s own CSS transform composes them into each viewer’s viewport pixels for free: no JavaScript per-frame repositioning, no resize-event nudging.
  • The container must be position: relative (or any non-static position) so absolute children anchor to it.
  • The container should use transform-origin: 0 0. This is the standard for canvas-style apps; with any other origin, cursor positions will be shifted by the origin offset. If you need a different visual origin, pre-bake the offset into the matrix’s translate component instead of changing transform-origin.
  • Only 2D affine transforms are supported (translate, scale, rotate, skew). 3D transforms aren’t read.
  • Local-cursor proximity / visibility math, the cursor SVG icon, and getCursorStyle continue to work unchanged.

The website/fridge.tsx demo (live at playhtml.fun/fridge) uses this pattern. Each user pans and pinch-zooms .content independently. With container: ".content", when one user hovers the word “love,” every other user sees their cursor glued to “love” in their own view, regardless of their own pan or zoom state.

<PlayProvider
  initOptions={{
    cursors: {
      enabled: true,
      container: ".content",
    },
  }}
>
  <div className="content">
    {/* pan/zoom transform applied here via React state */}
    {words.map(w => <FridgeWord key={w.id} {...w} />)}
  </div>
</PlayProvider>

The transform itself (e.g. translate(panX, panY) scale(scale)) is applied however you like: inline style.transform, a CSS class, or a CSS variable. playhtml just reads it.

Cursors expose a global window.cursors object for accessing user presence data.

// Get all user colors (across the room)
const colors = window.cursors.allColors;  // ["#3b82f6", "#8b5cf6", ...]

// Get/set your cursor color
window.cursors.color;  // "#3b82f6"
window.cursors.color = "#ff0000";

// Get/set your name
window.cursors.name;  // "Alice"
window.cursors.name = "Bob";

Listen for changes to cursor state:

// Listen for color changes
window.cursors.on('allColors', (colors) => {
  console.log(`${colors.length} users online`);
});

window.cursors.on('color', (myColor) => {
  console.log(`My color changed to ${myColor}`);
});

window.cursors.on('name', (myName) => {
  console.log(`My name changed to ${myName}`);
});

// Stop listening
window.cursors.off('allColors', callback);
<div id="user-count">👥 <span>0</span> online</div>

<script>
  const updateCount = () => {
    const count = window.cursors?.allColors?.length || 0;
    document.querySelector('#user-count span').textContent = count;
  };

  window.cursors?.on('allColors', updateCount);
  updateCount();
</script>
import { usePlayContext } from "@playhtml/react";

function UserCount() {
  const { cursors } = usePlayContext();

  return (
    <div>👥 {cursors.allColors.length} users online</div>
  );
}

The cursors object from usePlayContext() provides:

  • cursors.allColors - Array of all user colors
  • cursors.color - Your current color
  • cursors.name - Your current name

These values automatically update when users join/leave or change their settings.

import { usePlayContext } from "@playhtml/react";

function CursorSettings() {
  const { configureCursors, getMyPlayerIdentity } = usePlayContext();

  const changeColor = (color: string) => {
    // This updates window.cursors.color
    window.cursors.color = color;
  };

  const changeName = (name: string) => {
    // This updates window.cursors.name
    window.cursors.name = name;
  };

  return (
    <div>
      <input
        type="color"
        value={getMyPlayerIdentity().color}
        onChange={(e) => changeColor(e.target.value)}
      />
      <input
        type="text"
        value={getMyPlayerIdentity().name || ""}
        onChange={(e) => changeName(e.target.value)}
        placeholder="Your name"
      />
    </div>
  );
}

Show total users across your entire site while only displaying cursors from the current page:

playhtml.init({
  cursors: {
    enabled: true,
    room: "domain",  // All pages share presence
    shouldRenderCursor: (presence) => {
      // Only render same-page cursors
      return presence.page === window.location.pathname;
    }
  }
});

// Access global count
const totalUsers = window.cursors.allColors.length;

React:

<PlayProvider
  initOptions={{
    cursors: {
      enabled: true,
      room: "domain",
      shouldRenderCursor: (presence) =>
        presence.page === window.location.pathname
    }
  }}
>
  <UserCount />  {/* Shows domain-wide count */}
  {/* Cursors only appear from same page */}
</PlayProvider>

function UserCount() {
  const { cursors } = usePlayContext();
  return <div>👥 {cursors.allColors.length} online</div>;
}

Show cursors from all pages but make cross-page cursors appear as “ghosts”:

playhtml.init({
  cursors: {
    enabled: true,
    room: "domain",
    getCursorStyle: (presence) => {
      if (presence.page !== window.location.pathname) {
        return {
          opacity: '0.4',
          filter: 'blur(3px)'
        };
      }
      return {};
    }
  }
});

React:

<PlayProvider
  initOptions={{
    cursors: {
      enabled: true,
      room: "domain",
      getCursorStyle: (presence) =>
        presence.page !== window.location.pathname
          ? { opacity: '0.4', filter: 'blur(3px)' }
          : {}
    }
  }}
/>

Show cursors only to users in the same section of your site (e.g., all /blog/* pages):

playhtml.init({
  cursors: {
    enabled: true,
    room: "section"  // Groups by first path segment
  }
});

Create custom groupings based on your app’s logic:

playhtml.init({
  cursors: {
    enabled: true,
    room: ({ domain, pathname }) => {
      // Extract workspace ID from URL
      const match = pathname.match(/\/workspace\/(\w+)/);
      if (match) {
        return `${domain}-workspace-${match[1]}`;
      }
      return `${domain}${pathname}`;
    }
  }
});
  • Check that enabled: true is set
  • Verify the room configuration - users must be in the same room to see each other
  • Check browser console for connection errors
  • Make sure you’re reading window.cursors.allColors.length after initialization
  • Listen for the allColors event to get updates
  • Check that the room setting matches your intent (page vs domain)
  • Use shouldRenderCursor to filter by presence.page
  • Verify that presence.page matches your expectations
  • getCursorStyle returns must be valid CSS property values
  • Styles are applied via Object.assign(element.style, ...)
  • Check browser console for CSS errors
  • Ensure PlayProvider is rendered before components using usePlayContext()
  • The cursors object updates automatically - no manual subscription needed
  • Use getMyPlayerIdentity() for immediate reads, not reactive values