Marker
A marker is a small round button pinned on something, usually a photo, that opens more about that spot. It shows a plus, and turns into a cross while it is open.
import { Marker } from "@/components/ui/marker";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
export default function MarkerDemo() {
return (
<>
<Marker size="sm" aria-label="Small marker" />
<Marker aria-label="Marker" />
<Marker size="lg" aria-label="Large marker" />
<Popover>
<PopoverTrigger render={<Marker aria-label="Handlebars" />} />
<PopoverContent>
<PopoverTitle>Handlebars</PopoverTitle>
<PopoverDescription>A wide, flat bar with lock-on grips.</PopoverDescription>
</PopoverContent>
</Popover>
<Marker aria-label="Disabled marker" disabled />
</>
);
}The code
The component is a single file, read from the repository when the site is built. Press Show code to see all of it.
import type { ComponentProps } from "react";
import { Button as BaseButton } from "@base-ui/react/button";
import { Plus } from "lucide-react";
import { cx } from "@/lib/cx";
import { focusRing } from "@/lib/styles";
type Size = "sm" | "default" | "lg";
// A round "+" pinned on something, such as a photo, that opens more about that spot. While what it
// opens is showing, the plus turns a quarter of the way round and becomes a cross, and the fill
// switches to the accent, the library's active color. "Open" is read from aria-expanded, which a
// Popover trigger sets, so the Marker needs no state of its own. The dark fill and the 3px ring
// keep it visible on a light photo and on the dark page. The focus ring adds a 2px halo in the
// page color: over a light photo the light ring alone would vanish, and the halo gives the marker
// an edge there. Hover and press are guarded with not-data-[disabled], and the turn and the press
// scale are both skipped for reduced motion.
//
// To recolor it, set these custom properties on the marker or any parent; each falls back to the
// library's own color: --marker-fill and --marker-ink (the ring and the icon) when closed,
// --marker-hover-fill when pointed at, and --marker-open-fill and --marker-open-ink when open.
const base = [
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full border-3 border-[color:var(--marker-ink,var(--color-text-secondary))] bg-[var(--marker-fill,var(--color-surface))] text-[color:var(--marker-ink,var(--color-text-secondary))] select-none",
"[&_svg]:shrink-0 [&_svg]:transition-transform [&_svg]:duration-200 [&_svg]:ease-out motion-reduce:[&_svg]:transition-none",
"transition-[background-color,border-color,color,scale,filter] duration-150 ease-out",
focusRing,
"focus-visible:shadow-[0_0_0_2px_var(--color-bg)]",
"motion-safe:not-data-[disabled]:active:scale-95",
"not-aria-expanded:not-data-[disabled]:hover:bg-[var(--marker-hover-fill,var(--color-border))] not-aria-expanded:not-data-[disabled]:hover:text-[color:var(--marker-ink,var(--color-text))]",
"aria-expanded:border-[color:var(--marker-open-ink,var(--color-bg))] aria-expanded:bg-[var(--marker-open-fill,var(--color-accent))] aria-expanded:text-[color:var(--marker-open-ink,var(--color-bg))] aria-expanded:[&_svg]:rotate-45",
"aria-expanded:not-data-[disabled]:hover:brightness-90",
"data-[disabled]:cursor-not-allowed data-[disabled]:border-border data-[disabled]:text-muted",
].join(" ");
const sizes: Record<Size, string> = {
sm: "size-7 [&_svg]:size-3.5",
default: "size-9 [&_svg]:size-4",
lg: "size-12 [&_svg]:size-5",
};
export type MarkerProps = Omit<ComponentProps<typeof BaseButton>, "className" | "children"> & {
size?: Size;
className?: string;
};
// The marker has no text, so always give it an aria-label that names the spot it marks.
export function Marker({ size = "default", className, ...props }: MarkerProps) {
return (
<BaseButton className={cx(base, sizes[size], className)} {...props}>
<Plus aria-hidden />
</BaseButton>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
size | sm | default | lg | default | The diameter: 28px, 36px or 48px. |
disabled | boolean | false | Turns the marker off. |
aria-label | string | — | What the marker points at. It has no text, so it needs one. |
className | string | — | Extra classes, added after the marker's own. Use them to position it. |
Every other prop goes to the Base UI Button underneath. To open something from a marker, pass it as the trigger's render, as the demo does with Popover.
The thinking
A marker only points. What it opens, and where, is up to the Popover, Hover Card or Tooltip you pair it with.
- Built on Base UI: It is a Base UI Button, so it works with the keyboard like any button and can be disabled.
- No state of its own: It reads whether it is open from
aria-expanded, which a Popover trigger already sets. Pair it with anything that sets that attribute and it follows along. - Position it yourself: The marker doesn't place itself. Put it in a relatively positioned wrapper and offset it with a percentage, so it stays on the same spot as the image scales.
Interface design
- Sizes: Round at 28, 36 or 48px, with a 14, 16 or 20px plus, inside a 3px ring.
- Closed: A surface fill with a 3px ring and a plus, both in the secondary text color. The plus is 3.9:1 against the fill.
- Open: The fill changes to the accent, the ring and the icon to the page color (14.6:1 against the accent), and the plus turns 45 degrees into a cross.
- Made for images: The dark fill stands out on a light photo and the ring keeps it from disappearing on the dark page.
- Disabled: A quieter ring and a muted icon.
- Your own colors: Set
--marker-filland--marker-ink(the ring and the icon) for the closed marker,--marker-hover-fillfor hover, and--marker-open-filland--marker-open-inkfor the open one, on the marker or on any parent. Each one you leave out keeps the colors above. Product Hotspot dresses the marker in wine and white smoke this way.
Interaction
- Press it: Click it, or press Enter or Space when it has focus.
- Hover: A closed marker lightens its fill to the border color and brightens the plus to the main text color. An open one softens its accent fill to 85%.
- Turning: The plus turns into a cross over 200ms, and back when what it opened closes.
- While you press: The marker shrinks slightly, to 95%, over 150ms. People who ask for reduced motion get neither the turn nor the shrink.
- Focus: Keyboard focus draws a 2px outline, 2px away, with a dark halo in the gap so it also shows on a light photo.
Empathy
- Name every marker: An icon alone says nothing to a screen reader. Give each marker an aria-label that names its spot, like Handlebars, not Marker 1.
- Announced: Paired with a Popover, a screen reader hears that it is a button and whether it is expanded or collapsed.
- Not only a shape change: The cross is a shape, and the expanded state is announced, so no one depends on seeing the color change.
- Target size: Even the 28px size clears the 24px minimum of WCAG 2.2 AA.