presence
ui component, one file for any app
faces, a head count and what people are doing, in one chip.
states
settings
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 propertycountnumberpeople.lengththe whole head count, when you know of more people than you send; never below the people listedmax1–62bubbles in the stack, +N included: past it there are max − 1 faces and +Nactivitystring—the second line: what they are doingonlinebooleanfalsethe second line turns green, and says “online” when there is no activityaccentcss color—turns the ring on; its gradient and the tint of +N come from this colourlivebooleanfalsea light runs round the ring while something is going onlabelstringnamesreplaces the line of nameslayoutstacked | inlinestackedtext under the faces or beside theminteractivebooleanfalsea real button: focus, Enter, Space and lm-selecthrefurl—a link; lm-select comes first and can cancel the navigationdisabledbooleanfalsedimmed, no hover, no eventsloadingbooleanfalsea skeleton of the same size while the data is on its waystatehover | active | focus—holds the look of an interaction, for previews and screenshotslanglanguage tagnearest [lang]ru gets russian wording, anything else englisha 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 openingcss 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 boxbubblethe faces with their ringringthe coloured pill behind the facesfaceone personavatarthe picture inside a facemorethe +N bubblelabelthe line of namesstatusthe second linefor 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
- up to max people get faces. when there are more, the last place goes to +N: max − 1 faces, then +(count − faces).
- a count with nobody listed is one bubble with the number, and the line says “1,240 people”.
- 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.
- the bubble shows 1.2K, 12K, 1.5M; the line keeps the exact number.
- nobody: a dashed circle and “no one yet”.
- 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.
- 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.
