presence

ui component, one file for any app

faces, a head count and what people are doing, in one chip.

states

settings

people
1
faces
ring
text
background

install

the stage as html: load the script once, then write the element anywhere.

<script src="https://likemiens.com/ui/presence/presence.js" defer></script>

<lm-presence
  people='[
    {"name": "@mikepro", "avatar": "https://likemiens.com/ui/presence/avatars/mikepro.webp"}
  ]'
  online
  interactive
></lm-presence>

on any page: the script once, the element anywhere. it is served with no-cache, so fixes arrive by themselves; for a fixed copy, download the file.

presence.jspresence.d.tsgithubversion 1.0.3 · MIT

in a project: put presence.js next to your code and import it once — the import registers <lm-presence>, a second one does nothing.

import "./presence.js";

const chip = document.querySelector("lm-presence");
chip.people = [{ name: "@walentina", avatar: "/avatars/walentina.jpg" }, { name: "@kai" }];
chip.count = 12;
chip.addEventListener("lm-select", (event) => openRoom(event.detail));

react 19 passes properties and booleans straight to the element; events come through a ref. the script touches window when it loads, so with server rendering import it in an effect.

"use client";
import { useEffect, useRef } from "react";

export function Presence({ onSelect, people, ...props }) {
  const ref = useRef(null);
  // the script touches window when it loads: in the browser only
  useEffect(() => {
    import("./presence.js");
  }, []);
  useEffect(() => {
    const chip = ref.current;
    const select = (event) => onSelect?.(event.detail);
    chip.addEventListener("lm-select", select);
    return () => chip.removeEventListener("lm-select", select);
  }, [onSelect]);
  // a JSON string works even before the element is defined
  return <lm-presence ref={ref} people={JSON.stringify(people)} {...props} />;
}

<Presence people={people} count={12} activity="are running" accent="#3b1fd1" interactive onSelect={openRoom} />

vue: tell the compiler the tag is a custom element.

// vite.config.js
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith("lm-") } } })

<lm-presence :people="people" :count="count" activity="are running" accent="#3b1fd1" interactive
  @lm-select="openRoom($event.detail)" />

typescript: presence.d.ts types the element, querySelector("lm-presence"), the lm-select event and the version of the file.

/// <reference path="./presence.d.ts" />
import type { HTMLAttributes, Ref } from "react";

declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "lm-presence": HTMLAttributes<LmPresenceElement> &
        Partial<Omit<LmPresenceElement, keyof HTMLElement | "people" | "count">> & {
          people?: LmPresencePerson[] | string;
          count?: number | null;
          ref?: Ref<LmPresenceElement>;
        };
    }
  }
}

api

attributes and properties

an attribute and its property are the same thing. a boolean is on when present; “false” counts as off, so frameworks can pass true and false.

peoplePerson[][]who is there, in order; the first get faces. json in the attribute, an array or json in the property
countnumberpeople.lengththe whole head count, when you know of more people than you send; never below the people listed
max1–62bubbles in the stack, +N included: past it there are max − 1 faces and +N
activitystring—the second line: what they are doing
onlinebooleanfalsethe second line turns green, and says “online” when there is no activity
accentcss color—turns the ring on; its gradient and the tint of +N come from this colour
livebooleanfalsea light runs round the ring while something is going on
labelstringnamesreplaces the line of names
layoutstacked | inlinestackedtext under the faces or beside them
interactivebooleanfalsea real button: focus, Enter, Space and lm-select
hrefurl—a link; lm-select comes first and can cancel the navigation
disabledbooleanfalsedimmed, no hover, no events
loadingbooleanfalsea skeleton of the same size while the data is on its way
statehover | active | focus—holds the look of an interaction, for previews and screenshots
langlanguage tagnearest [lang]ru gets russian wording, anything else english

a person

type Person = {
  id?: string;     // keeps a face in place while the list changes; the name by default
  name: string;    // shown as is: "@walentina", "Anna Petrova"
  avatar?: string; // image url; initials while it loads or if it fails
  color?: string;  // background behind the initials; picked from the name by default
};

events

lm-selectCustomEvent<{ people, count }>on click, Enter or Space when interactive or href is set. it bubbles and leaves the shadow root; preventDefault() keeps a link from opening

css properties

font and text colour come from the page: style lm-presence like text.

--lm-presence-sizelength56pxheight of the faces; spacing, type and the ring follow it
--lm-presence-accentcolor—the accent attribute, set from css for many chips at once
--lm-presence-ringbackgroundfrom accentany background for the ring: a colour, a gradient, a picture
--lm-presence-more-bgcoloraccent tintbackground of the +N bubble
--lm-presence-more-colorcolornear blackthe number in it
--lm-presence-mutedcolor58% of textthe second line
--lm-presence-onlinecolor#18843c · #30d158the online line, light and dark color-scheme
--lm-presence-focuscolor#0c75f1the keyboard focus ring
--lm-presence-label-widthlength1.9 × sizethe narrowest column of text; longer names get “…”

parts

controlthe button, the link or the plain box
bubblethe faces with their ring
ringthe coloured pill behind the faces
faceone person
avatarthe picture inside a face
morethe +N bubble
labelthe line of names
statusthe second line

for anything else, ::part() reaches inside:

lm-presence::part(label) { font-weight: 600; }
lm-presence::part(more) { font-family: ui-rounded, system-ui; }

notes

how it decides

  1. up to max people get faces. when there are more, the last place goes to +N: max − 1 faces, then +(count − faces).
  2. a count with nobody listed is one bubble with the number, and the line says “1,240 people”.
  3. one name; two names joined by “&”; more — the first name and “+N others”. each name gets shorter on its own, “&” and “+N others” never do.
  4. the bubble shows 1.2K, 12K, 1.5M; the line keeps the exact number.
  5. nobody: a dashed circle and “no one yet”.
  6. a face is known by id, or by name: new ones pop in, those who left fade, the rest slide into place; +N rolls up when it grows and down when it shrinks.
  7. pictures fade in when loaded; a broken one turns into initials on a tinted circle.

accessibility

  • screen readers get the whole sentence with full names: “@walentina +2 others, are running”.
  • interactive and href render a real <button> and <a>: tab, Enter, Space and the focus ring come with them.
  • without them the chip is one picture for assistive tech, out of the tab order.
  • loading sets aria-busy; with prefers-reduced-motion the light, the pop and the spread are off and changes cross-fade.
  • the online green keeps 4.5:1 on white and on black.

more components