Map Marker Person
- Stable
- WCAG 2.2 evidence
- RTL
A person on a map: a ring and avatar with the person's initials, a work status dot and a pointer. Selected, it grows, gets a halo and
shows the name. The map is not part of the library: position the marker yourself with translate(-50%, -100%) so the pointer
tip sits on the coordinates. It is plain (an image named "Ayesha Khan, Working") unless you give it onPress (a button) or href (a link). Interactive
markers are React Aria buttons and links: Enter and Space press, keyboard focus draws the focus ring around the ring, hover
is data-hovered (WCAG 2.1.1, 2.4.7, 4.1.2). The selected marker has aria-current. What the marker is, a person, is
given by the initials and the name, and the work status by the name's text: colour never carries it alone (1.4.1). Consumer duties: show a person's location only with consent; show the
status as visible text somewhere sighted users can reach it (a card or list), because the dot colours alone do not satisfy 1.4.1;
keep neighbouring markers 24 px apart or large enough (2.5.8).
import { MapMarkerPerson } from "@nexera-ui/react";- 8
- examples
- 14
- props
- 3
- live controls
- 1
- platform
- 8
- WCAG criteria
- 2
- blocks use it
Try every prop. Copy the code.
Change the props and the code updates. Check light and dark, LTR and RTL, and phone width.
import { MapMarkerPerson } from "@nexera-ui/react";
<MapMarkerPerson />Examples 7
The same examples as Storybook, rendered live. Open Code to copy one.
With visible status
Consumer duty (WCAG 1.4.1): the dot colours alone do not tell the status, so the statuses are also written as text next to the markers (here a list). The markers' names include the status for assistive technology.
import { MapMarkerPerson } from "@nexera-ui/react";
const statuses = [
"working",
"remote",
"on-leave",
"offline",
] as const satisfies readonly MapMarkerWorkStatus[];
const statusText = {
working: "Working",
remote: "Remote",
"on-leave": "On leave",
offline: "Offline",
} as const;
export function WithVisibleStatus() {
return (
<ul className="m-0 flex list-none flex-col gap-3 p-0">
{statuses.map((status) => (
<li key={status} className="flex items-center gap-3">
<MapMarkerPerson name="Ayesha Khan" workStatus={status} />
<span className="text-body-default text-primary">
Ayesha Khan <span className="text-secondary">· {statusText[status]}</span>
</span>
</li>
))}
</ul>
);
}
Long name
A long name wraps inside the selected marker's label (up to 140 px wide) instead of stretching the marker.
import { MapMarkerPerson } from "@nexera-ui/react";
<MapMarkerPerson name="Alexandria Ocasio-Cortez-Hernandez-Villanueva" isSelected />;
Right to left
Right-to-left: the status dot moves to the bottom-left corner of the ring; the map itself is not mirrored.
import { MapMarkerPerson } from "@nexera-ui/react";
export function RightToLeft() {
return (
<div dir="rtl" lang="ar" className="flex flex-wrap items-end gap-8">
<MapMarkerPerson name="عائشة خان" workStatus="remote" workStatusLabel="عن بُعد" />
<MapMarkerPerson
name="عائشة خان"
workStatus="working"
workStatusLabel="في العمل"
isSelected
/>
</div>
);
}
Narrow width
Narrow container (320 px): markers are small; a wrapping row keeps them in view.
import { MapMarkerPerson } from "@nexera-ui/react";
const statuses = [
"working",
"remote",
"on-leave",
"offline",
] as const satisfies readonly MapMarkerWorkStatus[];
export function NarrowWidth() {
return (
<div className="flex max-w-xs flex-wrap items-end gap-6">
{statuses.map((status) => (
<MapMarkerPerson
key={status}
name="Ayesha Khan"
workStatus={status}
onPress={() => undefined}
/>
))}
</div>
);
}
Disabled
Disabled: dimmed, out of the tab order.
import { MapMarkerPerson } from "@nexera-ui/react";
<MapMarkerPerson name="Ayesha Khan" onPress={() => undefined} isDisabled />;
On a map
All four markers on a consumer-built map (the map-* tokens, light and dark): people (a button each; the selected one has a halo and its name), a place, a cluster and the user's own position. The map is not part of the library: the consumer positions each marker, here with translate(-50%, -100%) for pins and translate(-50%, -50%) for round markers. A map is not mirrored in right-to-left layouts, so the offsets are physical.
import { useState } from "react";
import { MapMarkerCluster, MapMarkerPerson, MapMarkerPlace, MapMarkerYou } from "@nexera-ui/react";
const PEOPLE = [
{ id: "ayesha", name: "Ayesha Khan", status: "working", x: 24, y: 52 },
{ id: "bilal", name: "Bilal Ahmed", status: "remote", x: 58, y: 38 },
{ id: "hina", name: "Hina Raza", status: "on-leave", x: 72, y: 78 },
] as const;
export function OnAMap() {
function Map() {
const [selected, setSelected] = useState<string>("bilal");
const pin = "absolute -translate-x-1/2 -translate-y-full";
const dot = "absolute -translate-x-1/2 -translate-y-1/2";
return (
<div
role="group"
aria-label="Team map"
className="bg-map-land relative h-80 w-[40rem] max-w-full overflow-hidden rounded-lg"
>
<div
aria-hidden="true"
className="bg-map-water absolute -start-8 -bottom-10 h-28 w-72 rounded-full"
/>
<div aria-hidden="true" className="bg-map-park absolute end-6 top-6 h-24 w-40 rounded-lg" />
<div aria-hidden="true" className="bg-map-road-casing absolute inset-x-0 top-[46%] h-3" />
<div aria-hidden="true" className="bg-map-road absolute inset-x-0 top-[47%] h-1.5" />
<div aria-hidden="true" className="bg-map-road-casing absolute inset-y-0 start-[44%] w-3" />
<div
aria-hidden="true"
className="bg-map-road-major absolute inset-y-0 start-[44.5%] w-1.5"
/>
<span
aria-hidden="true"
className="text-map-label text-body-caption absolute start-4 top-[41%]"
>
Main Boulevard
</span>
{PEOPLE.map((person) => (
<div
key={person.id}
className={`${pin} ${selected === person.id ? "z-10" : ""}`}
style={{ left: `${String(person.x)}%`, top: `${String(person.y)}%` }}
>
<MapMarkerPerson
name={person.name}
workStatus={person.status}
isSelected={selected === person.id}
onPress={() => {
setSelected(person.id);
}}
/>
</div>
))}
<div className={pin} style={{ left: "38%", top: "30%" }}>
<MapMarkerPlace name="Lahore HQ" headcount="24" onPress={() => undefined} />
</div>
<div className={dot} style={{ left: "84%", top: "50%" }}>
<MapMarkerCluster
count={12}
size="md"
aria-label="12 places, zoom in"
onPress={() => undefined}
/>
</div>
<div className={dot} style={{ left: "12%", top: "28%" }}>
<MapMarkerYou />
</div>
</div>
);
}
return <Map />;
}
Full team map
import { useState } from "react";
import {
Avatar,
Button,
FilterChip,
MapMarkerCluster,
MapMarkerPerson,
MapMarkerPlace,
MapMarkerYou,
SearchField,
Tooltip,
TooltipTrigger,
} from "@nexera-ui/react";
function TeamMapExample() {
const [selected, setSelected] = useState("Ayesha Khan");
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [centre, setCentre] = useState({ x: 0, y: 0 });
const people = [
{
name: "Ayesha Khan",
initials: "AK",
detail: "Floor 3, design pod",
status: "working" as const,
x: 280,
y: 150,
},
{
name: "Bilal Ahmed",
initials: "BA",
detail: "Floor 2, platform",
status: "working" as const,
x: 360,
y: 210,
},
{
name: "Omar Farooq",
initials: "OF",
detail: "Remote, Doha",
status: "remote" as const,
x: 650,
y: 150,
},
{
name: "Hina Raza",
initials: "HR",
detail: "On leave until Monday",
status: "on-leave" as const,
x: 680,
y: 370,
},
];
const visible = people.filter(
(p) => p.name.toLowerCase().includes(query.toLowerCase()) && (!filter || p.status === filter),
);
return (
<section
aria-label="Full team map"
className="box-border flex w-[1280px] flex-col gap-[14px] rounded-xl border border-default bg-surface p-6 text-primary"
>
<h3 className="m-0 text-base font-semibold leading-6">Team map with a synced people list</h3>
<p className="m-0 text-[13px] leading-[18px] text-muted">
Selecting a person in the list highlights their marker and centres the map; filters apply to
both.
</p>
<div className="flex overflow-hidden rounded-xl border border-default">
<aside
aria-label="People"
className="box-border flex w-[360px] shrink-0 flex-col gap-[10px] border-e border-default p-4"
>
<SearchField
aria-label="Search people"
placeholder="Search people, teams, requests"
value={query}
onChange={setQuery}
shortcut="Ctrl K"
/>
<div className="flex gap-[6px]">
{(
[
["On site", "working"],
["Remote", "remote"],
["On leave", "on-leave"],
] as const
).map(([label, status]) => (
<FilterChip
key={status}
label={label}
isSelected={filter === status}
onChange={(on) => {
setFilter(on ? status : null);
}}
/>
))}
</div>
{visible.map((p) => (
<Button
key={p.name}
variant="ghost"
onPress={() => {
setSelected(p.name);
setCentre({ x: 434 - (p.x + 36), y: 260 - (p.y + 52) });
}}
aria-pressed={selected === p.name}
className="w-full justify-start p-2"
style={{
height: 52,
background: selected === p.name ? "var(--color-nav-active-bg)" : undefined,
}}
>
<span className="flex items-center gap-[10px]">
<Avatar size="md" initials={p.initials} isDecorative />
<span className="flex flex-col items-start text-start">
<span className={selected === p.name ? "text-nav-active-fg" : "text-primary"}>
{p.name}
</span>
<span
className={`text-xs font-medium ${selected === p.name ? "text-secondary" : "text-muted"}`}
>
{p.detail}
</span>
</span>
</span>
</Button>
))}
{visible.length === 0 && (
<p role="status" className="text-sm text-muted">
No people match your filters.
</p>
)}
</aside>
<div
className="relative h-[520px] w-[868px] overflow-hidden bg-[#f9fafa]"
aria-label="Illustrative Lahore map"
>
<div
className="absolute inset-0 origin-center"
style={{
transform: `translate(${centre.x}px, ${centre.y}px) scale(${zoom})`,
transformOrigin: selected === "Ayesha Khan" ? "350px 240px" : "center",
}}
>
{/* Exact river vector downloaded from Figma 235:106. */}
<svg
aria-hidden="true"
className="absolute left-[535px] top-0 h-[520px] w-[175px]"
viewBox="0 0 175.228 521"
fill="none"
>
<path
d="M5.88672 0.5C-28.9933 156.5 119.247 260.5 58.2067 520.5H162.847C215.167 286.5 75.6467 156.5 110.527 0.5H5.88672Z"
fill="#CFE2EC"
stroke="black"
/>
</svg>
<div className="absolute left-[70px] top-[312px] h-[114px] w-[157px] rounded-xl bg-[#d8ecdf]" />
<div className="absolute left-[314px] top-[42px] h-[83px] w-[122px] rounded-xl bg-[#d8ecdf]" />
<div className="absolute left-[732px] top-[364px] h-[104px] w-[122px] rounded-xl bg-[#d8ecdf]" />
{Array.from({ length: 40 }, (_, i) => (
<div
key={i}
className="absolute rounded-[2px] bg-[#f1f4f3]"
style={{
left: (i * 97) % 872,
top: (i * 61) % 520,
width: 14 + ((i * 7) % 22),
height: 10 + ((i * 5) % 18),
}}
/>
))}
{[
{ x: 0, y: 234, w: 873, a: -2.39 },
{ x: 254, y: -4, w: 530, a: 78.62 },
{ x: 0, y: 78, w: 873, a: 2.39 },
{ x: 0, y: 416, w: 873, a: -2.73 },
{ x: 100, y: -2, w: 523, a: 84.25 },
{ x: 763, y: -2, w: 521, a: 86.16 },
].map((r, i) => (
<div
key={i}
className="absolute bg-white"
style={{
left: r.x,
top: r.y,
width: r.w,
height: i < 2 ? 8 : 5,
transform: `rotate(${r.a}deg)`,
transformOrigin: "left center",
boxShadow: i < 2 ? "0 0 0 2px #e3e7e5" : undefined,
}}
/>
))}
<span className="absolute left-[560px] top-10 text-xs text-[#646d68]">Gulberg III</span>
<span className="absolute left-[120px] top-[250px] text-xs text-[#646d68]">
Model Town
</span>
<span className="absolute left-[640px] top-[470px] text-xs text-[#646d68]">
DHA Phase 5
</span>
<MapMarkerPlace name="Lahore HQ" className="absolute left-[300px] top-[190px]" />
<span className="absolute left-[330px] top-[250px] text-xs text-[#646d68]">
Acme Lahore HQ
</span>
{visible
.filter((p) => filter !== null || query.length > 0 || p.status === "working")
.map((p) => (
<MapMarkerPerson
key={p.name}
name={p.name}
initials={p.initials}
workStatus={p.status}
isSelected={p.name === selected}
onPress={() => {
setSelected(p.name);
setCentre({ x: 434 - (p.x + 36), y: 260 - (p.y + 52) });
}}
className={
p.name === selected ? "absolute [&>span:first-child]:order-last" : "absolute"
}
style={{ left: p.x, top: p.y, width: p.name === selected ? 140 : 72 }}
/>
))}
{!filter && !query && (
<>
<MapMarkerPerson
name="Sara Malik"
initials="SM"
className="absolute left-[520px] top-[330px]"
/>
<MapMarkerCluster
count={8}
aria-label="8 people"
size="md"
className="absolute left-[150px] top-[360px]"
/>
</>
)}
<MapMarkerYou className="absolute left-[420px] top-[280px]" />
</div>
<div className="absolute end-4 top-4 flex flex-col rounded-lg bg-surface shadow-md">
<TooltipTrigger delay={200}>
<Button
variant="ghost"
aria-label="Zoom in"
onPress={() => {
setZoom((z) => Math.min(2.5, z + 0.25));
}}
>
+
</Button>
<Tooltip>Zoom in</Tooltip>
</TooltipTrigger>
<TooltipTrigger delay={200}>
<Button
variant="ghost"
aria-label="Zoom out"
onPress={() => {
setZoom((z) => Math.max(0.75, z - 0.25));
}}
>
−
</Button>
<Tooltip>Zoom out</Tooltip>
</TooltipTrigger>
</div>
<Button
variant="secondary"
className="absolute end-4 top-[110px]"
aria-label="Reset map view"
onPress={() => {
setZoom(1);
setSelected("Ayesha Khan");
}}
>
⌖
</Button>
</div>
</div>
</section>
);
}
export function FullTeamMap() {
return <TeamMapExample />;
}
Props 14
Press "Try it" on a card to load that prop into the playground.
14 props shown
name*NexerastringThe person's name. It is the marker's accessible name, together with the work status (WCAG 1.1.1, 4.1.2), and the visible label when the marker is selected.
initialsNexerastringOne or two letters shown in the avatar. Decorative: the name carries the person.
workStatusNexera"working" | "remote" | "on-leave" | "offline"Work status: the colour of the dot on the avatar. working is green, remote cyan, on-leave violet,
offline grey. The status is part of the accessible name as text.
workStatusLabelNexerastringText for the work status in the accessible name, for example "Im Homeoffice". Translate it.
isSelectedNexerabooleanWhether the person is the selected marker: a larger pin with a halo and the name label, and
aria-current for assistive technology. Hover (State=Hover) is a pointer state that React Aria drives.
idNexerastringDOM id.
aria-describedbyNexerastringId(s) of the element(s) that describe the marker (for example a card with the person's role and place).
classNameNexerastringExtra classes, merged last so they win over the defaults.
styleNexeraCSSPropertiesInline style (typically the position on the map).
onPressNexera((event: PressEvent) => void) | ((event: PressEvent) => void)Called when the marker is pressed (pointer, Enter or Space). Makes the marker a button. Also called when the link is pressed.
hrefNexerastringMakes the marker a link to this URL.
targetNexerastringLink target.
relNexerastringLink relationship.
isDisabledNexerabooleanDisables the button. Disables the link.
* Required. React Aria props shown are the ones most apps use; the component accepts the rest of its React Aria props too.
Accessible by default.
Built on React Aria, and covered by the WCAG 2.2 evidence generated on every build.
WCAG 2.2 evidence
5 direct · 3 supporting- 1.1.1Non-text ContentLevel A · tested directly
- 1.3.2Meaningful SequenceLevel A · supporting test
- 1.4.1Use of ColorLevel A · tested directly
- 1.4.11Non-text ContrastLevel AA · supporting test
- 1.4.12Text SpacingLevel AA · supporting test
- 2.1.1KeyboardLevel A · tested directly
- 2.4.7Focus VisibleLevel AA · tested directly
- 4.1.2Name, Role, ValueLevel A · tested directly
Fits any width.
Nexera components respond to the space they are given. Drag the corner of the frame, or pick a width.
Styling hooks
Pass className to add Tailwind classes (merged last). State is exposed as data attributes, so you can style it with variants like data-pressed:.
data-disabled
<MapMarkerPerson className="data-disabled:opacity-90 shadow-sm" />Used in blocks
Related components
- AccordionItemOne collapsible section of an FAQ or settings page.
- AreaChartA stacked area chart: layers piled up over time with the title, range switch, actions and legend of the card.
- AvailabilityRowOne person's availability across a window of the day: an avatar with initials and the name, and a timeline with the busy periods drawn as bars.
- AvatarA person or organisation shown as a photo, initials or an icon.
- AvatarGroupA row of overlapping avatars with a "+N" chip for the rest.
- AvatarLabelAn avatar with a name and an optional subtitle beside it, for a person in a list, a header or a mention.
- BadgeShort status or category label: `Tone` x `Variant` x `Size`, with an optional dot, leading icon and remove button.
- BadgeGroupA pill that pairs a small badge with a short message, for an announcement or a status line.