component · 85/108
Map Frame
A labelled map region that hosts any map library, anchors overlays to its edges, and shows a placeholder basemap while tiles load.
Install
Install manuallyView source on GitHub
Anatomy
- MapFrame
- MapFramePlaceholder
- MapFrameSurface
- MapMarker
- MapMarkerIcon
- MapFrameOverlay
- MapControls
- MapControlsGroup
- MapControlsZoomIn
- MapControlsZoomOut
- MapControlsLocate
- MapFrameAttribution
Accessibility5 checks
- The frame is a section landmark named by aria-label, and aria-busy marks it while tiles or data load.
- The placeholder basemap is decorative and hidden from assistive technology. The loading state keeps a visible “Loading map tiles…” line; the error state is role="alert" with a real Try again button.
- Overlays keep pointer events only on their own controls, so the map stays draggable around them.
- Attribution stays visible as text in muted (not subtle) foreground on its own solid pill, so its contrast never depends on the tiles.
- In the preview, markers are toggle buttons with aria-pressed, the open place card is a group named by its title and describes the selected marker, and Escape closes it.
Maps
Café Bené
OpenCoffee · $$4 min walk★ 4.7
© Uai sample basemap · OpenStreetMap
Code example
usage.tsx"use client";import { Coffee, Croissant, MapPin } from "lucide-react";import { useEffect, useRef, useState } from "react";import { MapControls, MapControlsGroup, MapControlsLocate, type MapControlsLocateStatus, MapControlsZoomIn, MapControlsZoomOut,} from "@/components/ui/uai/map-controls";import { MapFrame, MapFrameAttribution, MapFrameOverlay, MapFramePlaceholder, MapFrameSurface, type MapFrameVariant,} from "@/components/ui/uai/map-frame";import { MapMarker, MapMarkerIcon } from "@/components/ui/uai/map-marker";export type MapFramePreviewState = "ready" | "loading" | "error" | "empty";type Place = { id: string; name: string; kind: "coffee" | "bakery"; status: { label: string; open: boolean }; meta: string[]; left: string; top: string; /** Which side of the marker the popover grows toward, so it stays inside the frame. */ align: "start" | "end";};const places: Place[] = [ { id: "bene", name: "Café Bené", kind: "coffee", status: { label: "Open", open: true }, meta: ["Coffee · $$", "4 min walk", "★ 4.7"], left: "40%", top: "62%", align: "start", }, { id: "lume", name: "Padaria Lume", kind: "bakery", status: { label: "Open", open: true }, meta: ["Bakery · $", "7 min walk", "★ 4.5"], left: "68%", top: "72%", align: "end", }, { id: "grao", name: "Grão Torrado", kind: "coffee", status: { label: "Closed", open: false }, meta: ["Coffee · $$", "11 min walk", "★ 4.8"], left: "18%", top: "44%", align: "start", },];const pop = "cubic-bezier(0.16, 1, 0.3, 1)";const reduced = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;const canAnimate = (el: HTMLElement | null): el is HTMLElement => typeof el?.animate === "function";/** The overlay surface: a translucent popover layer with a strong ring and a soft shadow. */const floating = "bg-[color-mix(in_oklab,var(--popover)_92%,transparent)] shadow-[0_0_0_1px_var(--border-strong),0_10px_28px_oklch(0_0_0/0.12)] backdrop-blur-[12px] dark:shadow-[0_0_0_1px_var(--border-strong),0_10px_28px_oklch(0_0_0/0.42)]";function PlacePopover({ place, closing, onClosed,}: { place: Place; closing: boolean; onClosed: () => void;}) { const ref = useRef<HTMLDivElement>(null); const onClosedRef = useRef(onClosed); onClosedRef.current = onClosed; useEffect(() => { if (!canAnimate(ref.current)) return; ref.current.animate( reduced() ? { opacity: [0, 1] } : { opacity: [0, 1], transform: ["scale(0.96)", "scale(1)"] }, { duration: 180, easing: pop }, ); }, []); useEffect(() => { if (!closing) return; if (!canAnimate(ref.current)) { onClosedRef.current(); return; } const exit = ref.current.animate( reduced() ? { opacity: [1, 0] } : { opacity: [1, 0], transform: ["scale(1)", "scale(0.98)"] }, { duration: 120, easing: "ease-out", fill: "forwards" }, ); exit.onfinish = () => onClosedRef.current(); return () => exit.cancel(); }, [closing]); const titleId = `place-${place.id}-title`; return ( // biome-ignore lint/a11y/useSemanticElements: a labelled popover card, not a form fieldset. <div ref={ref} id={`place-${place.id}`} role="group" aria-labelledby={titleId} className={`absolute bottom-[calc(100%+10px)] z-30 grid w-59 gap-1.5 rounded-xl p-3 text-left ${floating} after:absolute after:-bottom-1.5 after:size-3 after:rotate-45 after:rounded-br-[2px] after:bg-popover after:shadow-[1px_1px_0_0_var(--border-strong)] ${ place.align === "start" ? "-left-[14px] origin-[30px_calc(100%+6px)] after:left-6" : "-right-[14px] origin-[calc(100%-30px)_calc(100%+6px)] after:right-6" }`} > <div className="flex items-center justify-between gap-3"> <p id={titleId} className="m-0 truncate text-[13.5px]/5 font-medium"> {place.name} </p> <span className={`shrink-0 rounded-full px-2 text-[11.5px]/5 font-medium ${ place.status.open ? "bg-success/14 text-success" : "bg-accent text-muted-foreground" }`} > {place.status.label} </span> </div> <p className="m-0 flex flex-wrap gap-x-1.5 text-[12px]/4 text-muted-foreground tabular-nums"> {place.meta.map((part, index) => ( <span key={part}> {index > 0 && ( <span aria-hidden="true" className="me-1.5"> · </span> )} {part} </span> ))} </p> </div> );}export function MapFramePreview({ variant = "card", state = "ready",}: { variant?: MapFrameVariant; state?: MapFramePreviewState;}) { const [zoom, setZoom] = useState(14); const [locate, setLocate] = useState<MapControlsLocateStatus>("idle"); const [selected, setSelected] = useState<string | null>("bene"); // The popover on screen. It trails `selected` so one popover fades out before the next pops in. const [shown, setShown] = useState<string | null>("bene"); const [failed, setFailed] = useState(state === "error"); const searchRef = useRef<HTMLButtonElement>(null); useEffect(() => setFailed(state === "error"), [state]); useEffect(() => { if (shown === null && selected !== null) setShown(selected); }, [selected, shown]); useEffect(() => { if (state !== "empty" || !canAnimate(searchRef.current) || reduced()) return; searchRef.current.animate( { opacity: [0, 1], transform: ["translateY(4px)", "translateY(0)"] }, { duration: 200, easing: pop }, ); }, [state]); // After "Try again" the error clears and the map shows the same places the chip counts. const visible = state === "loading" || state === "empty" || failed ? [] : places; const compact = variant === "compact"; return ( <MapFrame variant={variant} aria-label="Cafés in Savassi, Belo Horizonte" busy={state === "loading"} style={{ height: 340 }} onKeyDown={(event) => { if (event.key === "Escape" && selected) setSelected(null); }} > {state === "loading" || failed ? ( <MapFramePlaceholder status={failed ? "error" : "loading"} onRetry={() => setFailed(false)} /> ) : ( <MapFramePlaceholder /> )} <MapFrameSurface onClick={() => setSelected(null)}> {/* Mount your map library here, for example a MapLibre or Leaflet container. */} <div /> </MapFrameSurface> {!failed && visible.map((place) => { const isSelected = selected === place.id; return ( <div key={place.id} className={isSelected || shown === place.id ? "z-20" : undefined} style={{ position: "absolute", left: place.left, top: place.top, translate: "-50% -100%", }} > {isSelected && ( <span key={place.id} aria-hidden="true" className="pointer-events-none absolute top-1/2 left-1/2 size-14 -translate-1/2 animate-ring-pulse rounded-full border-[1.5px] border-primary/45 text-primary [animation-iteration-count:2] motion-reduce:animate-none" /> )} <MapMarker label={place.name} tone={isSelected ? "primary" : "default"} selected={isSelected} aria-describedby={isSelected ? `place-${place.id}` : undefined} onClick={() => setSelected(isSelected ? null : place.id)} > <MapMarkerIcon> {place.kind === "coffee" ? <Coffee /> : <Croissant />} </MapMarkerIcon> </MapMarker> {shown === place.id && ( <PlacePopover place={place} closing={selected !== place.id} onClosed={() => setShown(selected)} /> )} </div> ); })} {!failed && ( <MapFrameOverlay position="top-left"> <p className={`m-0 inline-flex h-8 items-center gap-2 rounded-full pr-3 pl-2.5 text-[13px]/[18px] font-medium ${floating}`} > <MapPin aria-hidden="true" className="size-3.5 text-muted-foreground" /> {state === "empty" ? ( "No cafés in this area" ) : ( <span> {compact ? "Savassi" : "Savassi, Belo Horizonte"} <span className="font-normal text-subtle-foreground tabular-nums"> {" "} · {state === "loading" ? "Finding cafés…" : `${places.length} cafés`} </span> </span> )} </p> {state === "empty" && ( <button ref={searchRef} type="button" className="h-6.5 w-max cursor-pointer rounded-full border-0 bg-transparent px-3 text-[12px]/4 font-medium text-foreground shadow-[0_0_0_1px_var(--border-strong)] transition-[background-color,scale] duration-[120ms,140ms] ease-[ease-out,cubic-bezier(0.23,1,0.32,1)] outline-none hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100" > Search this area </button> )} </MapFrameOverlay> )} <MapFrameOverlay position="top-right"> <MapControls variant={compact ? "compact" : "stacked"}> <MapControlsGroup> <MapControlsZoomIn disabled={zoom >= 18} onClick={() => setZoom((z) => z + 1)} /> <MapControlsZoomOut disabled={zoom <= 10} onClick={() => setZoom((z) => z - 1)} /> </MapControlsGroup> <MapControlsGroup> <MapControlsLocate status={locate} onClick={() => setLocate((s) => (s === "active" ? "idle" : "active"))} /> </MapControlsGroup> </MapControls> </MapFrameOverlay> <MapFrameAttribution>© Uai sample basemap · OpenStreetMap</MapFrameAttribution> </MapFrame> );}Manual installation
map-frame.tsxLoading component source…