Vela UI Docsvenizia.ai

Doc Presence

Collaboration marks for a shared document: overlapping collaborator avatars, a peer's inline text caret with a name flag, and a block outlined in the peer's colour.

npx shadcn@latest add @velaui/doc-presence
npx shadcn@latest add @velaui/doc-presence

Examples

  • Collaborators

View code

Doc Presence · Collaborators

Installation

pnpm dlx velaui add @velaui/doc-presence
pnpm dlx velaui add @velaui/doc-presence

Code

doc-presence-collaborators.tsx
import * as React from "react";

import { DocPeerBlock, DocPeerCaret, DocPresenceBar, type DocPeer } from "@/components/ui/doc-presence";
import { DocTypography } from "@/components/ui/doc-typography";

const LINH: DocPeer = { id: "linh", name: "Linh", color: "#2563eb" };
const MINH: DocPeer = { id: "minh", name: "Minh", color: "#db2777" };
const TAIL = " — owner confirmed, freeze starts Friday";

export default function Example() {
  const [title, setTitle] = React.useState("Billing cut-over");
  const [typed, setTyped] = React.useState(0);
  // Minh's typing is simulated: one character every 90ms, then a pause and again.
  React.useEffect(() => {
    const t = setInterval(() => setTyped((n) => (n >= TAIL.length + 12 ? 0 : n + 1)), 90);
    return () => clearInterval(t);
  }, []);
  const line = "Steps are agreed with finance" + TAIL.slice(0, typed);

  return (
    <div className="w-full max-w-xl space-y-5 p-6">
      <div className="flex items-center justify-between">
        <span className="text-xs text-text-muted">2 others editing</span>
        <DocPresenceBar peers={[LINH, MINH]} />
      </div>
      <DocPeerBlock peer={LINH}>
        <DocTypography type="h2" value={title} onChange={setTitle} />
      </DocPeerBlock>
      <p className="text-sm leading-[1.6]">
        {line}
        <DocPeerCaret peer={MINH} />
      </p>
    </div>
  );
}

Usage

import * as React from "react";

import { DocPeerBlock, DocPeerCaret, DocPresenceBar, type DocPeer } from "@/components/ui/doc-presence";
import { DocTypography } from "@/components/ui/doc-typography";

const LINH: DocPeer = { id: "linh", name: "Linh", color: "#2563eb" };
const MINH: DocPeer = { id: "minh", name: "Minh", color: "#db2777" };
const TAIL = " — owner confirmed, freeze starts Friday";

export default function Example() {
  const [title, setTitle] = React.useState("Billing cut-over");
  const [typed, setTyped] = React.useState(0);
  // Minh's typing is simulated: one character every 90ms, then a pause and again.
  React.useEffect(() => {
    const t = setInterval(() => setTyped((n) => (n >= TAIL.length + 12 ? 0 : n + 1)), 90);
    return () => clearInterval(t);
  }, []);
  const line = "Steps are agreed with finance" + TAIL.slice(0, typed);

  return (
    <div className="w-full max-w-xl space-y-5 p-6">
      <div className="flex items-center justify-between">
        <span className="text-xs text-text-muted">2 others editing</span>
        <DocPresenceBar peers={[LINH, MINH]} />
      </div>
      <DocPeerBlock peer={LINH}>
        <DocTypography type="h2" value={title} onChange={setTitle} />
      </DocPeerBlock>
      <p className="text-sm leading-[1.6]">
        {line}
        <DocPeerCaret peer={MINH} />
      </p>
    </div>
  );
}

Props

DocPresenceBar

PropTypeDefault
classNamestring—
peersrequiredDocPeer[]—

Takes these and every prop of { peers: DocPeer[]; className?: string }.

DocPeerCaret

PropTypeDefault
peerrequiredDocPeer—

Takes these and every prop of { peer: DocPeer }.

DocPeerBlock

PropTypeDefault
childrenrequiredstring | number | bigint | boolean | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | React.ReactPortal | Promise<string | number | bigint | boolean | React.ReactPortal | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | null | undefined>—
classNamestring—
peerrequiredDocPeer—

Takes these and every prop of { peer: DocPeer; className?: string; children: React.ReactNode }.

Dependencies

npm
none besides React
Installs with it
nothing else

Source

components/ui/doc-presence.tsx
import * as React from "react";

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

export interface DocPeer {
  id: string;
  name: string;
  /** Any CSS colour; the caret, flag and avatar use it, with black or white text chosen for contrast. */
  color: string;
}

/** Black or white ink, whichever contrasts with `color` (CSS relative colour: light backgrounds get black). */
const ink = (color: string) => `oklch(from ${color} clamp(0, (0.7 - l) * 1000, 1) 0 0)`;

/** Who else is in the document: overlapping initials, with the full name on hover. */
function DocPresenceBar({ peers, className }: { peers: DocPeer[]; className?: string }) {
  return (
    <div className={cn("flex items-center -space-x-1.5", className)} role="list" aria-label="Collaborators">
      {peers.map((peer) => (
        <span
          key={peer.id}
          role="listitem"
          title={peer.name}
          style={{ backgroundColor: peer.color, color: ink(peer.color) }}
          className="flex size-6 items-center justify-center rounded-full border-2 border-bg-main text-xs font-semibold"
        >
          {peer.name.slice(0, 1).toUpperCase()}
        </span>
      ))}
    </div>
  );
}

/** A peer's text caret, drawn inline between two runs of text, with their name on a flag above it. */
function DocPeerCaret({ peer }: { peer: DocPeer }) {
  return (
    <span
      aria-label={`${peer.name}'s cursor`}
      style={{ borderColor: peer.color }}
      className="relative mx-px inline-block h-[1.1em] translate-y-[0.2em] border-l-2"
    >
      <span
        style={{ backgroundColor: peer.color, color: ink(peer.color) }}
        className="absolute bottom-full left-0 mb-px whitespace-nowrap rounded-sm rounded-bl-none px-1 text-xs leading-4 font-medium"
      >
        {peer.name}
      </span>
    </span>
  );
}

/** A block a peer is working in: outlined in their colour and tagged with their name. */
function DocPeerBlock({ peer, className, children }: { peer: DocPeer; className?: string; children: React.ReactNode }) {
  return (
    <div style={{ outlineColor: peer.color }} className={cn("relative rounded-sm outline-1 outline-offset-2", className)}>
      <span
        style={{ backgroundColor: peer.color, color: ink(peer.color) }}
        className="absolute -top-4 right-0 rounded-sm px-1 text-xs leading-4 font-medium"
      >
        {peer.name}
      </span>
      {children}
    </div>
  );
}

export { DocPresenceBar, DocPeerCaret, DocPeerBlock };