Exclusivebeta
Whereabouts — <mv-whereabouts>
Team status board shaped like a dial whose hands point to what people are doing instead of the time. States sit around the rim as labeled sectors (icon, label and head count: Available, Focus, In a meeting, On call, Commuting, Away, Out of office, plus any app-defined state), and each person is a slim hand ending in an avatar or initials disc. Hands pointing to the same state fan out, then stack in shorter rows, so discs never overlap; when someone changes state their hand swings across on a spring (it jumps under reduced motion). Hovering, focusing or clicking a hand shows who it is, the state, since when and until when (“Until 3:30 PM · in 40 min”), what comes next and a teammate's local time when they are in another time zone. set(id, state, { until, then }) moves someone and schedules the automatic return (a meeting that ends puts them back to Available), mv-state-change is cancelable, and clicking a sector filters the board. The same data switches to a grouped list on its own when the container is narrow or when a sector is too crowded for the dial, and people and states come from properties or from plain child markup that stays readable without JavaScript.
| Category | Data display |
|---|---|
| Type | Web Component (<mv-whereabouts>) |
| Status | beta |
| Keywords | exclusive, culture, presence, status, team, availability, who-is-around, dial, radial, avatars, time-zones, remote, on-call, dashboard, filter, spring, roving-tabindex, live-region |
When to use
- A team page or dashboard should show at a glance who is available, heads-down, in a meeting or away right now
- A distributed team needs each person's status together with when it ends and their local time
- An ops or support rota must make on-call, off-shift and available people obvious in one small panel
- Presence updates arrive live and the board should show who moved without a jarring re-sort
Avoid when
- Only one person's presence is shown, next to their name or picture → use Avatar instead
- The roster is long (dozens of people) and must be sorted, searched and paginated → use Data table instead
- Items move through workflow stages that people drag between, rather than showing live presence → use Kanban instead
Install
node scripts/add.mjs whereabouts --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["whereabouts"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/whereabouts/whereabouts.js, components/whereabouts/whereabouts.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="wa-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#wa-demo { display:grid; grid-template-columns:minmax(0,1fr) 18rem; gap:1rem; align-items:start }
#wa-demo .wa-card { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#wa-demo .wa-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.875rem 1rem .75rem 1.125rem; border-bottom:1px solid var(--mv-border) }
#wa-demo .wa-title { display:grid; gap:.125rem; min-width:0 }
#wa-demo .wa-title strong { font-size:.9375rem; letter-spacing:-.01em }
#wa-demo .wa-title span { color:var(--mv-fg-muted); font-size:.75rem }
#wa-demo .wa-body { padding:1.25rem 1rem 1rem }
#wa-demo .wa-foot { display:flex; align-items:center; justify-content:space-between; gap:.625rem 1rem; flex-wrap:wrap; padding:.75rem 1rem .75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle); border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl) }
#wa-demo .wa-actions { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap }
#wa-demo .wa-live { display:inline-flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.8125rem; cursor:pointer }
#wa-demo .wa-log { min-height:1.1rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1 1 14rem; min-width:0 }
#wa-demo .wa-side .wa-body { padding:.5rem .5rem .75rem }
#wa-demo .wa-side .wa-head { padding:.75rem .875rem .625rem }
@media (max-width:52rem) { #wa-demo { grid-template-columns:minmax(0,1fr) } }
</style>
<!-- Main board: the dial -->
<section class="wa-card" aria-labelledby="wa-main-title">
<header class="wa-head">
<span class="wa-title">
<strong id="wa-main-title">Platform team</strong>
<span>12 people · 9 time zones · click a state to filter</span>
</span>
<mv-segmented id="wa-layout" value="auto" aria-label="Layout" data-size="sm">
<button value="auto">Auto</button>
<button value="dial">Dial</button>
<button value="list">List</button>
</mv-segmented>
</header>
<div class="wa-body">
<mv-whereabouts id="wa-main" label="Platform team whereabouts"></mv-whereabouts>
</div>
<footer class="wa-foot">
<span class="wa-actions">
<button type="button" class="mv-button" data-size="sm" id="wa-standup">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="8.5" r="3"/><path d="M3.5 19.5a5.5 5.5 0 0 1 11 0"/><path d="M15.5 5.8a3 3 0 0 1 0 5.4"/><path d="M17.5 14.2a5.5 5.5 0 0 1 3 5.3"/></svg>
Start standup
</button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="wa-shuffle">Simulate an update</button>
<label class="wa-live"><input type="checkbox" role="switch" class="mv-switch" id="wa-auto"> Live updates</label>
</span>
<p class="wa-log" id="wa-log" aria-hidden="true">Ben’s 1:1 ends in a few seconds…</p>
</footer>
</section>
<!-- Same data in a narrow sidebar: switches to the list layout by itself -->
<aside class="wa-card wa-side" aria-labelledby="wa-side-title">
<header class="wa-head">
<span class="wa-title">
<strong id="wa-side-title">Who’s around</strong>
<span>Same data, sidebar width</span>
</span>
</header>
<div class="wa-body">
<mv-whereabouts id="wa-side" label="Who’s around" quiet></mv-whereabouts>
</div>
</aside>
<script type="module">
const main = document.getElementById("wa-main");
const side = document.getElementById("wa-side");
const log = document.getElementById("wa-log");
const boards = [main, side];
const now = Date.now();
const min = 60_000;
const at = (m) => new Date(now + m * min);
const tomorrowAt = (h, m = 0) => { const d = new Date(now + 864e5); d.setHours(h, m, 0, 0); return d; };
const states = [
...main.constructor.defaultStates,
{ id: "pairing", label: "Pairing", icon: "link", tone: "light-dark(oklch(0.56 0.16 330), oklch(0.74 0.14 330))" },
];
const people = [
{ id: "ben", name: "Ben Carter", state: "meeting", since: at(-26), until: new Date(now + 9_000), then: "available", note: "Customer call", timeZone: "America/New_York" },
{ id: "priya", name: "Priya Raman", state: "focus", since: at(-35), until: at(55), timeZone: "Asia/Kolkata" },
{ id: "mateo", name: "Mateo Rossi", state: "meeting", since: at(-20), until: at(25), note: "Sprint planning", timeZone: "Europe/Rome" },
{ id: "aiko", name: "Aiko Tanaka", state: "ooo", since: at(-60 * 26), until: tomorrowAt(9), then: "available", note: "Back tomorrow", timeZone: "Asia/Tokyo" },
{ id: "lucas", name: "Lucas Oliveira", state: "available", since: at(-12), timeZone: "America/Sao_Paulo" },
{ id: "amara", name: "Amara Okafor", state: "available", since: at(-48), timeZone: "Africa/Lagos" },
{ id: "jonas", name: "Jonas Weber", state: "commuting", since: at(-14), until: at(20), then: "available", timeZone: "Europe/Berlin" },
{ id: "sofia", name: "Sofía Hernández", state: "on-call", since: at(-180), until: tomorrowAt(9), note: "Primary for payments", timeZone: "America/Mexico_City" },
{ id: "noah", name: "Noah Kim", state: "pairing", since: at(-30), until: at(30), note: "With Elena on the billing migration", timeZone: "America/Los_Angeles" },
{ id: "elena", name: "Elena Petrova", state: "pairing", since: at(-30), until: at(30), note: "With Noah on the billing migration", timeZone: "Europe/Sofia" },
{ id: "omar", name: "Omar Haddad", state: "away", since: at(-9), until: at(15), then: "available", note: "Lunch", timeZone: "Asia/Dubai" },
{ id: "chloe", name: "Chloé Martin", state: "focus", since: at(-70), until: at(40), timeZone: "Europe/Paris" },
];
for (const b of boards) { b.states = states; b.people = people; }
const time = (d) => d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
const apply = (id, state, opts) => boards.every((b) => b.set(id, state, opts));
main.addEventListener("mv-state-change", (e) => {
const { person, to, until, source } = e.detail;
const label = states.find((s) => s.id === to)?.label ?? to;
log.textContent = `${time(new Date())} · ${person.name} → ${label}${until ? ` until ${time(until)}` : ""}${source === "schedule" ? " (scheduled)" : ""}`;
});
main.addEventListener("mv-filter", (e) => {
log.textContent = e.detail.state ? `Filter: ${states.find((s) => s.id === e.detail.state)?.label}` : "Filter cleared";
});
document.getElementById("wa-layout").addEventListener("mv-change", (e) => { main.layout = e.detail.value; });
document.getElementById("wa-standup").addEventListener("click", () => {
const until = new Date(Date.now() + 15 * min);
for (const p of main.people) {
if (["available", "focus"].includes(p.state)) apply(p.id, "meeting", { until, then: p.state, note: "Daily standup" });
}
});
const moves = [
["focus", 45, "available"], ["meeting", 30, "available"], ["available"], ["away", 10, "available"],
["commuting", 25, "available"], ["on-call", 240], ["pairing", 60, "focus"],
];
const shuffle = () => {
const list = main.people.filter((p) => p.state !== "ooo");
const p = list[Math.floor(Math.random() * list.length)];
const options = moves.filter(([s]) => s !== p.state);
const [state, mins, then] = options[Math.floor(Math.random() * options.length)];
apply(p.id, state, mins ? { until: new Date(Date.now() + mins * min), then } : {});
};
document.getElementById("wa-shuffle").addEventListener("click", shuffle);
let timer = 0;
document.getElementById("wa-auto").addEventListener("change", (e) => {
clearInterval(timer);
if (e.target.checked) { shuffle(); timer = setInterval(() => { if (!main.isConnected) clearInterval(timer); else shuffle(); }, 4000); }
});
</script>
</div>Cultural reference
Harry Potter and the Chamber of Secrets — J. K. Rowling (1998, book). A family clock in the kitchen has one hand per family member, and instead of hours its hands point to where each person is: home, work, traveling, mortal peril. The UI turns it into a team status board where each teammate is a hand pointing to a state (Focus, In a meeting, Away…), fanning out when they share one and swinging over when their status changes.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
layout | auto | dial | list | auto | auto shows the dial unless the component is narrower than breakpoint or a sector cannot hold its people without overlap (even with smaller discs), then the grouped list. The effective layout is exposed as data-layout on the element. |
breakpoint | number (px) | 360 | Container width under which auto switches to the list (measured with a ResizeObserver, so it follows the container, not the viewport). |
size | number (px) | 440 | Maximum dial diameter. The dial shrinks with its container; disc size follows (26 to 34 px) and drops further when a sector is crowded. |
filter | state id | Only people in this state stay active: other hands fade and leave the tab order (inert), other list groups are hidden, and a “Showing 2 · Focus / Show everyone” bar appears. Set by clicking a sector or a list group heading; reflected. | |
label | string | Team whereabouts | Accessible name of the dial and of the list. |
sectors | comma-separated state ids | Which states get a sector and in what order, clockwise from the top (e.g. "available, focus, meeting, away"). A person in a state that is not listed still gets a sector: nobody is ever dropped. | |
time-zone | IANA time zone | the browser's | Zone used to display since / until times and to decide who is remote (a teammate is remote when their current UTC offset differs from this one). |
locale | BCP 47 tag | en-US | Locale for times and dates ("3:30 PM", "tomorrow 9:00 AM", "Mon, Sep 28"). |
quiet | boolean | No live announcements (use it on a second board that mirrors the same data, so changes are not spoken twice). | |
data-person / data-state / data-since / data-until / data-then / data-note / data-avatar / data-time-zone / data-name / data-initials | child markup | Any descendant with data-person="id" is a person (its text is the name unless data-name is set). Read on connection and re-read when the markup changes; ignored once the people property is set. set() writes the new state back to these attributes. | |
data-state-id / data-icon / data-tone | child markup | Any descendant with data-state-id adds a state to the built-in ones (or overrides one with the same id); its text is the label. |
Properties
| Name | Type | Description |
|---|---|---|
people | Array<{ id, name, state, since?, until?, then?, note?, avatar?, timeZone?, initials? }> | The people on the board. since / until accept a Date, an ISO string or epoch ms; then is the state applied automatically at until; avatar is an image URL (initials are shown underneath and whenever the image fails); timeZone is an IANA zone. Assigning a new array diffs it with the current one: people whose state changed swing to their new sector and emit mv-state-change with source "data". Reading it returns copies reflecting every change made with set(). |
states | Array<{ id, label, icon?, tone? }> | Replaces the built-in states. icon: available, focus, meeting, on-call, commuting, away, ooo, home, moon, pin, link, headphones, phone, flask, star, globe, dot, or raw SVG path data on a 24×24 grid. tone: success, accent, danger, warning, info, neutral, or any CSS color (light-dark() works). Start from MvWhereabouts.defaultStates to add your own. |
MvWhereabouts.defaultStates | static, read-only | Copies of the built-in states, to spread before app-defined ones. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (label, people, filterGroup, person, persons, showing ({count}, {state}), nobody, showEveryone, since, until, then, sinceLong, untilLong, thenLong ({time}, {duration}, {state}), local, ahead, behind, localSpoken ({time}, {city}, {diff}), tomorrow, summary, announceMany, more). English defaults. |
layout / breakpoint / size / filter / label / sectors / timeZone / locale / quiet | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
set(personId, state, { until?, then?, since?, note? }) | Moves someone to a state. Emits the cancelable mv-state-change first; returns false if it was canceled or the person is unknown. since defaults to now when the state changes; note is cleared on a state change unless given; then is applied automatically when until passes (source "schedule"). |
person(id) | Returns a copy of one person, or null. |
focusPerson(id) | Moves keyboard focus to that person's hand (or list row). Returns false if they are unknown or faded out by the filter. |
Events
| Name | Description |
|---|---|
mv-state-change | Someone's state (or end time) changes. detail: { id, person (copy, before the change), from, to, since, until, then, source: "api" | "schedule" | "data" }. Cancelable for api and schedule: preventDefault() keeps the current state (a vetoed schedule is dropped, not retried). data changes are reported after the fact. |
mv-person-focus | A hand or list row received focus. detail: { id, person, layout }. |
mv-person-activate | A hand or list row was clicked or activated with Enter / Space (open a profile, start a chat…). On the dial this also pins the detail card. detail: { id, person, layout }. |
mv-filter | The user changed the filter from a sector, a list heading, the Show everyone button or Escape. detail: { state (null when cleared), previous, source: "sector" | "list" | "keyboard" | "button" }. |
Content structure
| Name | Description |
|---|---|
(content) | Optional source markup: elements with data-person (people) and data-state-id (extra states), e.g. a <ul> of names. It is the no-JavaScript fallback and is hidden once the component is ready; the board is rendered after it. |
CSS classes
| Name | Description |
|---|---|
mv-whereabouts-dial | The dial (role=group): .mv-whereabouts-ring (SVG: -bezel, -face, -guides, one .mv-whereabouts-sector per state with -wedge, -band, -label, -sector-icon, -count), .mv-whereabouts-stems (-stem), .mv-whereabouts-pivot, .mv-whereabouts-hands (-hand buttons with an .mv-whereabouts-avatar), .mv-whereabouts-card. data-filtered and data-spotlight are set on the dial; data-active, data-dim and data-state on hands. |
mv-whereabouts-card | Detail card: -card-head, -card-avatar, -card-name, -card-state, -card-line, -card-note, -card-local, -card-rel. data-open, data-pinned, data-side (right | left | top | bottom: placed outward along the hand first, kept inside the component and the viewport). |
mv-whereabouts-list | Grouped list: .mv-whereabouts-group (per state) with -group-head (filter button: -group-icon, -group-name, -group-count), -items and .mv-whereabouts-row buttons (-avatar, -row-name, -row-detail, -row-local with -row-time, -row-city; data-night after 10 PM and before 7 AM local). |
mv-whereabouts-filterbar | Shown while filtered: -filterbar-text and the .mv-whereabouts-clear button. |
CSS variables
| Name | Type | Default | Description |
|---|---|---|---|
--mv-whereabouts-success / -accent / -danger / -warning / -info / -neutral | the matching --mv-* token (neutral: --mv-fg-subtle) | Color of each named tone, used by sectors, disc rings, stems and list icons. | |
--_tone | output | Set on every sector, hand, stem, row and group: the state's resolved color, usable in your own styles. |
Accessibility
The dial is a labelled group preceded by a complete text equivalent, a visually hidden list read in browse mode (“12 people on the board. Available: Lucas Oliveira, Amara Okafor. In a meeting: Mateo Rossi (until 3:30 PM)…”). Every hand is a real <button> whose accessible name is a full sentence (“Mateo Rossi. In a meeting, until 3:30 PM, since 2:45 PM. Sprint planning. Local time 9:50 PM in Rome.”); hands share one Tab stop (roving tabindex) in clockwise order: arrow keys move to the next or previous person, Home / End to the first or last, Page Down / Page Up to the next or previous state, and typing a name jumps to it. The sectors are a second group of toggle buttons (role=button, aria-pressed, name “Focus, 2 people”) with their own Tab stop and arrow keys; Enter or Space filters, Escape clears the filter or unpins the card. Filtered-out hands are made inert, and focus moves to the first remaining person if it was on one of them. The detail card is aria-hidden because it repeats the accessible name. Changes are announced in a polite live region, batched and throttled to one message every 3 seconds (“Mateo Rossi: In a meeting, until 3:30 PM.” or “3 status updates. …”), never on first render, and not at all with quiet. States are never conveyed by color alone: each sector has an icon, a label and a count, the list groups repeat them, and hands point into the labeled sector. The list layout is a labelled group of headings-as-filter-buttons (aria-pressed) and rows with the same full-sentence names, and focus is kept on the same person when the layout switches. Reduced motion (OS setting or data-motion="reduce"): hands jump to their new position with no swing, the entrance sweep and hover scaling are skipped. Forced colors: sectors, discs, stems and focus rings use system colors, and the active filter uses Highlight.