shadcnmaps
uimapsource: ok · hit
Map
Core SVG map renderer with region interaction, tooltips, and markers. Required by all map components.
npx shadcn add "https://shadcnmaps.com/r/map.json"free
Interaction
tooltip
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files8 · source cached 10/8/2026, 1:55:10 PM
registry/shadcnmaps/map.tsx
'use client'
import { cn } from '@/lib/utils'
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import { MapProvider, useMapContext } from './map-context'
import { MapListbox } from './map-listbox'
import { MapMarker } from './map-marker'
import { MapRegion } from './map-region'
import { MapTooltip } from './map-tooltip'
import type {
MapData,
MapMarkerData,
MapRegionData,
MarkerEvent,
RegionEvent,
RegionOverride,
ZoomConfig,
} from './types'
export interface MapProps {
data: MapData
selectedRegion?: string | null
regions?: RegionOverride[]
markers?: MapMarkerData[]
disabledRegions?: string[]
onRegionClick?: (event: RegionEvent) => void
onRegionEnter?: (event: RegionEvent) => void
onRegionLeave?: (event: RegionEvent) => void
onMarkerClick?: (event: MarkerEvent) => void
showLabels?: boolean
showTooltips?: boolean
renderTooltip?: (region: MapRegionData) => ReactNode
className?: string
children?: ReactNode
'aria-label'?: string
enableZoom?: boolean
zoomConfig?: Partial<ZoomConfig>
controls?: ReactNode
}
function getPointerPosition(
nativeEvent: unknown
): { x: number; y: number } | null {
if (
typeof nativeEvent === 'object' &&
nativeEvent !== null &&
'clientX' in nativeEvent &&
'clientY' in nativeEvent &&
typeof nativeEvent.clientX === 'number' &&
typeof nativeEvent.clientY === 'number'
) {
return { x: nativeEvent.clientX, y: nativeEvent.clientY }
}
const maybeTouches = (
nativeEvent as {
touches?: {
length: number
item?: (index: number) => unknown
[index: number]: unknown
}
}
).touches
if (maybeTouches && maybeTouches.length > 0) {
const touch =
typeof maybeTouches.item === 'function'
? maybeTouches.item(0)
: maybeTouches[0]
if (
typeof touch === 'object' &&
touch !== null &&
'clientX' in touch &&
'clientY' in touch &&
typeof touch.clientX === 'number' &&
typeof touch.clientY === 'number'
) {
return { x: touch.clientX, y: touch.clientY }
}
}
return null
}
function parseViewBox(viewBox: string) {
const parts = viewBox.split(/\s+/).map(Number)
return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
}
function MapInner({
data,
regions,
markers,
disabledRegions,
onRegionClick,
onRegionEnter,
onRegionLeave,
onMarkerClick,
showLabels = true,
showTooltips = true,
renderTooltip,
className,
children,
'aria-label': ariaLabel,
enableZoom = false,
}: MapProps) {
const {
selectedRegion,
setSelectedRegion,
focusedRegion,
setTooltipState,
zoomState,
setZoomState,
zoomConfig,
} = useMapContext()
const descId = useId()
const [announcement, setAnnouncement] = useState('')
const svgRef = useRef<SVGSVGElement>(null)
const isPanningRef = useRef(false)
const [isGesture, setIsGesture] = useState(false)
const dragStartRef = useRef<{
x: number
y: number
tx: number
ty: number
} | null>(null)
const pinchStartRef = useRef<{ dist: number; scale: number } | null>(null)
const regionOverrides = useMemo(() => {
return new Map(regions?.map((region) => [region.id, region]) ?? [])
}, [regions])
const disabledRegionSet = useMemo(() => {
return new Set(disabledRegions ?? [])
}, [disabledRegions])
const mergedRegions = useMemo(() => {
return data.regions.map((baseRegion) => {
const override = regionOverrides.get(baseRegion.id)
return {
...baseRegion,
...override,
}
})
}, [data.regions, regionOverrides])
const mapLabel = ariaLabel ?? data.name
const viewBox = useMemo(() => parseViewBox(data.viewBox), [data.viewBox])
const clampTranslate = useCallback(
(tx: number, ty: number, scale: number) => {
const maxPanX = (viewBox.width * (scale - 1)) / 2 + viewBox.width * 0.5
const maxPanY = (viewBox.height * (scale - 1)) / 2 + viewBox.height * 0.5
return {
tx: Math.max(-maxPanX, Math.min(maxPanX, tx)),
ty: Math.max(-maxPanY, Math.min(maxPanY, ty)),
}
},
[viewBox]
)
// Wheel zoom
useEffect(() => {
if (!enableZoom) return
const svg = svgRef.current
if (!svg) return
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
const delta = e.deltaY > 0 ? -zoomConfig.zoomStep : zoomConfig.zoomStep
setZoomState((prev) => {
const newScale = Math.max(
zoomConfig.minZoom,
Math.min(zoomConfig.maxZoom, prev.scale + delta)
)
if (newScale === prev.scale) return prev
// Auto-center when fully zoomed out
if (newScale === zoomConfig.minZoom) {
return { scale: newScale, translateX: 0, translateY: 0 }
}
// Zoom toward cursor position
const rect = svg.getBoundingClientRect()
const cursorX = e.clientX - rect.left
const cursorY = e.clientY - rect.top
const svgX = (cursorX / rect.width) * viewBox.width + viewBox.x
const svgY = (cursorY / rect.height) * viewBox.height + viewBox.y
const scaleRatio = newScale / prev.scale
const newTx = svgX - scaleRatio * (svgX - prev.translateX)
const newTy = svgY - scaleRatio * (svgY - prev.translateY)
const clamped = clampTranslate(newTx, newTy, newScale)
return {
scale: newScale,
translateX: clamped.tx,
translateY: clamped.ty,
}
})
}
svg.addEventListener('wheel', handleWheel, { passive: false })
return () => svg.removeEventListener('wheel', handleWheel)
}, [enableZoom, zoomConfig, setZoomState, viewBox, clampTranslate])
// Click-and-drag pan — use document-level move/up so we don't steal
// pointer events from child <path> elements (setPointerCapture would
// prevent region clicks from firing).
const handlePointerDown = useCallback(
(e: ReactPointerEvent<SVGSVGElement>) => {
if (!enableZoom || e.button !== 0) return
// Only start drag from the SVG background or zoom layer, not from regions
const target = e.target as Element
if (target.closest('[data-slot="map-region"]')) {
// Store start position so we can detect drag even from a region
isPanningRef.current = false
setIsGesture(true)
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
tx: zoomState.translateX,
ty: zoomState.translateY,
}
return
}
isPanningRef.current = false
setIsGesture(true)
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
tx: zoomState.translateX,
ty: zoomState.translateY,
}
},
[enableZoom, zoomState.translateX, zoomState.translateY]
)
// Document-level pointermove/pointerup for drag panning
useEffect(() => {
if (!enableZoom) return
const handlePointerMove = (e: PointerEvent) => {
if (!dragStartRef.current) return
const dx = e.clientX - dragStartRef.current.x
const dy = e.clientY - dragStartRef.current.y
// Movement threshold to distinguish from click
if (!isPanningRef.current && Math.hypot(dx, dy) < 3) return
isPanningRef.current = true
const svg = svgRef.current
if (!svg) return
const rect = svg.getBoundingClientRect()
const svgDx = (dx / rect.width) * viewBox.width * zoomState.scale
const svgDy = (dy / rect.height) * viewBox.height * zoomState.scale
const newTx = dragStartRef.current.tx + svgDx
const newTy = dragStartRef.current.ty + svgDy
const clamped = clampTranslate(newTx, newTy, zoomState.scale)
setZoomState((prev) => ({
...prev,
translateX: clamped.tx,
translateY: clamped.ty,
}))
}
const handlePointerUp = () => {
if (!dragStartRef.current) return
dragStartRef.current = null
// Delay clearing so click handlers can check isPanning
requestAnimationFrame(() => {
isPanningRef.current = false
setIsGesture(false)
})
}
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
return () => {
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
}
}, [enableZoom, viewBox, zoomState.scale, setZoomState, clampTranslate])
// Touch pinch-to-zoom
useEffect(() => {
if (!enableZoom) return
const svg = svgRef.current
if (!svg) return
const getTouchDist = (e: TouchEvent) => {
if (e.touches.length < 2) return null
const dx = e.touches[0].clientX - e.touches[1].clientX
const dy = e.touches[0].clientY - e.touches[1].clientY
return Math.hypot(dx, dy)
}
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
e.preventDefault()
const dist = getTouchDist(e)
if (dist !== null) {
pinchStartRef.current = { dist, scale: zoomState.scale }
}
}
}
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length === 2 && pinchStartRef.current) {
e.preventDefault()
const dist = getTouchDist(e)
if (dist !== null) {
const scaleRatio = dist / pinchStartRef.current.dist
const newScale = Math.max(
zoomConfig.minZoom,
Math.min(
zoomConfig.maxZoom,
pinchStartRef.current.scale * scaleRatio
)
)
setZoomState((prev) => ({ ...prev, scale: newScale }))
}
}
}
const handleTouchEnd = (e: TouchEvent) => {
if (e.touches.length < 2) {
pinchStartRef.current = null
}
}
svg.addEventListener('touchstart', handleTouchStart, { passive: false })
svg.addEventListener('touchmove', handleTouchMove, { passive: false })
svg.addEventListener('touchend', handleTouchEnd)
return () => {
svg.removeEventListener('touchstart', handleTouchStart)
svg.removeEventListener('touchmove', handleTouchMove)
svg.removeEventListener('touchend', handleTouchEnd)
}
}, [enableZoom, zoomConfig, zoomState.scale, setZoomState])
const shouldSuppressClick = useCallback(() => {
return enableZoom && isPanningRef.current
}, [enableZoom])
const svgContent = (
<>
{mergedRegions.map((region) => {
const disabled = region.disabled || disabledRegionSet.has(region.id)
const tooltipContent =
renderTooltip?.(region) ?? region.tooltipContent ?? region.name
return (
<MapRegion
key={region.id}
id={region.id}
name={region.name}
path={region.path}
abbreviation={showLabels ? region.abbreviation : undefined}
labelX={showLabels ? region.labelX : undefined}
labelY={showLabels ? region.labelY : undefined}
metadata={region.metadata}
className={region.className}
labelClassName={region.labelClassName}
disabled={disabled}
onClick={(event) => {
if (shouldSuppressClick()) return
const isCurrentlySelected = selectedRegion === region.id
setSelectedRegion(isCurrentlySelected ? null : region.id)
setAnnouncement(
isCurrentlySelected
? `${region.name} deselected`
: `${region.name} selected`
)
onRegionClick?.(event)
if (showTooltips) {
setTooltipState({
visible: true,
content: tooltipContent,
position: getPointerPosition(event.nativeEvent),
})
}
}}
onEnter={(event) => {
onRegionEnter?.(event)
if (showTooltips) {
setTooltipState((current) => ({
visible: true,
content: tooltipContent,
position:
getPointerPosition(event.nativeEvent) ?? current.position,
}))
}
}}
onMove={(event) => {
if (!showTooltips) {
return
}
setTooltipState((current) => ({
...current,
visible: true,
content: tooltipContent,
position:
getPointerPosition(event.nativeEvent) ?? current.position,
}))
}}
onLeave={(event) => {
onRegionLeave?.(event)
if (showTooltips) {
setTooltipState((current) => ({ ...current, visible: false }))
}
}}
/>
)
})}
{markers?.map((marker) => (
<MapMarker
key={marker.id}
id={marker.id}
x={marker.x}
y={marker.y}
content={marker.content}
label={marker.label}
disabled={marker.disabled}
onClick={(event) => {
if (shouldSuppressClick()) return
onMarkerClick?.(event)
if (showTooltips && marker.tooltipContent) {
setTooltipState({
visible: true,
content: marker.tooltipContent,
position: getPointerPosition(event.nativeEvent),
})
}
}}
onEnter={(event) => {
if (showTooltips && marker.tooltipContent) {
setTooltipState({
visible: true,
content: marker.tooltipContent,
position: getPointerPosition(event.nativeEvent),
})
}
}}
onMove={(event) => {
if (!showTooltips || !marker.tooltipContent) {
return
}
setTooltipState((current) => ({
...current,
visible: true,
content: marker.tooltipContent,
position:
getPointerPosition(event.nativeEvent) ?? current.position,
}))
}}
onLeave={() => {
if (showTooltips) {
setTooltipState((current) => ({ ...current, visible: false }))
}
}}
/>
))}
{children}
<FocusRingOverlay focusedRegion={focusedRegion} regions={mergedRegions} />
</>
)
return (
<>
<svg
ref={svgRef}
data-slot='map'
role='group'
aria-label={mapLabel}
aria-describedby={descId}
viewBox={data.viewBox}
className={cn('h-auto w-full', className)}
style={
enableZoom
? {
touchAction: 'none',
cursor: zoomState.scale > 1 ? 'grab' : undefined,
}
: undefined
}
onPointerDown={enableZoom ? handlePointerDown : undefined}
onPointerLeave={() => {
if (showTooltips) {
setTooltipState((current) => ({ ...current, visible: false }))
}
}}
>
<desc id={descId}>
Interactive map. Tab to focus, arrow keys to navigate between regions,
Enter or Space to select, Escape to clear.
</desc>
{enableZoom ? (
<g
data-slot='map-zoom-layer'
transform={`translate(${zoomState.translateX},${zoomState.translateY}) scale(${zoomState.scale})`}
style={{
transition: isGesture ? 'none' : 'transform 200ms ease-out',
willChange: 'transform',
}}
>
{svgContent}
</g>
) : (
svgContent
)}
</svg>
<MapListbox
regions={mergedRegions}
disabledRegions={disabledRegionSet}
label={mapLabel}
onSelect={(region) => {
const isCurrentlySelected = selectedRegion === region.id
setAnnouncement(
isCurrentlySelected
? `${region.name} deselected`
: `${region.name} selected`
)
onRegionClick?.({ region, nativeEvent: null })
}}
/>
<div
role='status'
aria-live='polite'
aria-atomic='true'
className='sr-only'
>
{announcement}
</div>
{showTooltips ? <MapTooltipContainer /> : null}
</>
)
}
function FocusRingOverlay({
focusedRegion,
regions,
}: {
focusedRegion: string | null
regions: MapRegionData[]
}) {
if (!focusedRegion) return null
const region = regions.find((r) => r.id === focusedRegion)
if (!region) return null
return (
<path
data-slot='map-focus-ring'
d={region.path}
aria-hidden='true'
className='pointer-events-none fill-none stroke-map-region-focus-ring stroke-2'
/>
)
}
function MapTooltipContainer() {
const { tooltipState } = useMapContext()
if (!tooltipState.content) {
return null
}
return (
<MapTooltip
visible={tooltipState.visible}
content={tooltipState.content}
position={tooltipState.position}
className='shadcnmaps-tooltip'
/>
)
}
function MapRoot({
enableZoom,
zoomConfig,
controls,
selectedRegion: selectedRegionProp,
...props
}: MapProps) {
const viewBox = useMemo(
() => parseViewBox(props.data.viewBox),
[props.data.viewBox]
)
return (
<MapProvider
zoomConfig={zoomConfig}
viewBox={viewBox}
selectedRegion={selectedRegionProp}
>
<div data-slot='map-container' className='relative'>
<MapInner enableZoom={enableZoom} {...props} />
{controls}
</div>
</MapProvider>
)
}
export { MapRoot as Map }
registry/shadcnmaps/map-context.tsx
'use client'
import {
createContext,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react'
import type { ZoomConfig, ZoomState } from './types'
export interface TooltipState {
visible: boolean
content: ReactNode | null
position: { x: number; y: number } | null
}
const DEFAULT_ZOOM_STATE: ZoomState = {
scale: 1,
translateX: 0,
translateY: 0,
}
const DEFAULT_ZOOM_CONFIG: ZoomConfig = {
minZoom: 1,
maxZoom: 8,
zoomStep: 0.5,
panStep: 50,
}
interface MapContextValue {
selectedRegion: string | null
setSelectedRegion: Dispatch<SetStateAction<string | null>>
hoveredRegion: string | null
setHoveredRegion: Dispatch<SetStateAction<string | null>>
focusedRegion: string | null
setFocusedRegion: Dispatch<SetStateAction<string | null>>
tooltipState: TooltipState
setTooltipState: Dispatch<SetStateAction<TooltipState>>
zoomState: ZoomState
setZoomState: Dispatch<SetStateAction<ZoomState>>
zoomConfig: ZoomConfig
zoomIn: () => void
zoomOut: () => void
resetZoom: () => void
containerRef: RefObject<HTMLDivElement | null>
}
const MapContext = createContext<MapContextValue | null>(null)
interface MapProviderProps {
children: ReactNode
selectedRegion?: string | null
zoomConfig?: Partial<ZoomConfig>
viewBox?: { x: number; y: number; width: number; height: number }
}
function zoomTowardCenter(
prev: ZoomState,
newScale: number,
viewBox: { x: number; y: number; width: number; height: number }
): ZoomState {
const cx = viewBox.x + viewBox.width / 2
const cy = viewBox.y + viewBox.height / 2
const scaleRatio = newScale / prev.scale
return {
scale: newScale,
translateX: cx - scaleRatio * (cx - prev.translateX),
translateY: cy - scaleRatio * (cy - prev.translateY),
}
}
export function MapProvider({
children,
selectedRegion: selectedRegionProp,
zoomConfig: zoomConfigProp,
viewBox,
}: MapProviderProps) {
const isControlled = selectedRegionProp !== undefined
const [uncontrolledRegion, setUncontrolledRegion] = useState<string | null>(
null
)
const selectedRegion = isControlled ? selectedRegionProp : uncontrolledRegion
const noop = useCallback<
Dispatch<SetStateAction<string | null>>
>(() => {}, [])
const setSelectedRegion = isControlled ? noop : setUncontrolledRegion
const [hoveredRegion, setHoveredRegion] = useState<string | null>(null)
const [focusedRegion, setFocusedRegion] = useState<string | null>(null)
const [tooltipState, setTooltipState] = useState<TooltipState>({
visible: false,
content: null,
position: null,
})
const [zoomState, setZoomState] = useState<ZoomState>(DEFAULT_ZOOM_STATE)
const containerRef = useRef<HTMLDivElement>(null)
const zoomConfig = useMemo<ZoomConfig>(
() => ({ ...DEFAULT_ZOOM_CONFIG, ...zoomConfigProp }),
[zoomConfigProp]
)
const zoomIn = useCallback(() => {
setZoomState((prev) => {
const newScale = Math.min(
prev.scale + zoomConfig.zoomStep,
zoomConfig.maxZoom
)
if (!viewBox || newScale === prev.scale)
return { ...prev, scale: newScale }
return zoomTowardCenter(prev, newScale, viewBox)
})
}, [zoomConfig, viewBox])
const zoomOut = useCallback(() => {
setZoomState((prev) => {
const newScale = Math.max(
prev.scale - zoomConfig.zoomStep,
zoomConfig.minZoom
)
if (newScale === prev.scale) return prev
// Auto-center when fully zoomed out
if (newScale === zoomConfig.minZoom) {
return { scale: newScale, translateX: 0, translateY: 0 }
}
if (!viewBox) return { ...prev, scale: newScale }
return zoomTowardCenter(prev, newScale, viewBox)
})
}, [zoomConfig, viewBox])
const resetZoom = useCallback(() => {
setZoomState(DEFAULT_ZOOM_STATE)
}, [])
const value = useMemo<MapContextValue>(
() => ({
selectedRegion,
setSelectedRegion,
hoveredRegion,
setHoveredRegion,
focusedRegion,
setFocusedRegion,
tooltipState,
setTooltipState,
zoomState,
setZoomState,
zoomConfig,
zoomIn,
zoomOut,
resetZoom,
containerRef,
}),
[
focusedRegion,
hoveredRegion,
selectedRegion,
setSelectedRegion,
tooltipState,
zoomState,
zoomConfig,
zoomIn,
zoomOut,
resetZoom,
]
)
return <MapContext.Provider value={value}>{children}</MapContext.Provider>
}
export function useMapContext() {
const context = useContext(MapContext)
if (!context) {
throw new Error('useMapContext must be used within a MapProvider.')
}
return context
}
registry/shadcnmaps/map-region.tsx
'use client'
import { cn } from '@/lib/utils'
import { useMapContext } from './map-context'
import type { MapRegionData, RegionEvent } from './types'
export interface MapRegionProps extends MapRegionData {
selected?: boolean
hovered?: boolean
disabled?: boolean
className?: string
labelClassName?: string
onClick?: (event: RegionEvent) => void
onEnter?: (event: RegionEvent) => void
onLeave?: (event: RegionEvent) => void
onMove?: (event: RegionEvent) => void
}
export function MapRegion({
id,
path,
name,
abbreviation,
labelX,
labelY,
metadata,
selected,
hovered,
disabled,
className,
labelClassName,
onClick,
onEnter,
onLeave,
onMove,
}: MapRegionProps) {
const { hoveredRegion, selectedRegion, setHoveredRegion } = useMapContext()
const isSelected = selected ?? selectedRegion === id
const isHovered = hovered ?? hoveredRegion === id
const regionData: MapRegionData = {
id,
path,
name,
abbreviation,
labelX,
labelY,
metadata,
}
return (
<>
<path
data-slot='map-region'
d={path}
role='button'
tabIndex={-1}
aria-label={name ?? id}
aria-pressed={isSelected}
aria-disabled={disabled || undefined}
className={cn(
'cursor-pointer fill-map-region stroke-map-region-stroke stroke-1 outline-none motion-safe:transition-colors motion-safe:duration-150',
isHovered && 'fill-map-region-hover stroke-map-region-stroke-hover',
isSelected && 'fill-map-region-selected',
disabled && 'cursor-default fill-map-region-disabled opacity-60',
className
)}
onClick={(nativeEvent) => {
if (disabled) {
return
}
onClick?.({ region: regionData, nativeEvent })
}}
onMouseEnter={(nativeEvent) => {
if (disabled) {
return
}
setHoveredRegion(id)
onEnter?.({ region: regionData, nativeEvent })
}}
onMouseLeave={(nativeEvent) => {
if (disabled) {
return
}
setHoveredRegion((current) => (current === id ? null : current))
onLeave?.({ region: regionData, nativeEvent })
}}
onMouseMove={(nativeEvent) => {
if (disabled) {
return
}
onMove?.({ region: regionData, nativeEvent })
}}
/>
{abbreviation && labelX !== undefined && labelY !== undefined ? (
<text
data-slot='map-region-label'
x={labelX}
y={labelY}
aria-hidden='true'
className={cn(
'pointer-events-none fill-map-label text-[10px] font-medium select-none motion-safe:transition-colors motion-safe:duration-150',
isHovered && 'fill-map-label-hover',
isSelected && 'fill-map-label-selected',
labelClassName
)}
textAnchor='middle'
dominantBaseline='middle'
>
{abbreviation}
</text>
) : null}
</>
)
}
registry/shadcnmaps/map-tooltip.tsx
'use client'
import { cn } from '@/lib/utils'
import { type ReactNode, useCallback, useRef } from 'react'
import { createPortal } from 'react-dom'
export interface MapTooltipProps {
visible: boolean
content: ReactNode
position?: { x: number; y: number } | null
className?: string
}
const OFFSET_Y = 10
export function MapTooltip({
visible,
content,
position,
className,
}: MapTooltipProps) {
const ref = useRef<HTMLDivElement>(null)
const clamp = useCallback(
(node: HTMLDivElement | null) => {
ref.current = node
if (!node) return
const rect = node.getBoundingClientRect()
const clampedLeft = Math.min(
Math.max(rect.width / 2, node.offsetLeft),
window.innerWidth - rect.width / 2
)
if (clampedLeft !== node.offsetLeft) {
node.style.left = `${clampedLeft}px`
}
if (rect.top < 0) {
node.style.transform = 'translate(-50%, 0)'
node.style.top = `${(position?.y ?? 0) + OFFSET_Y}px`
}
},
[position]
)
if (typeof document === 'undefined' || !position) {
return null
}
return createPortal(
<div
ref={clamp}
data-slot='map-tooltip'
aria-hidden={!visible}
className={cn(
'pointer-events-none fixed z-50 rounded-lg border border-border bg-background px-3 py-2 text-sm font-medium shadow-xl transition-opacity duration-150 ease-out',
visible ? 'opacity-100' : 'opacity-0',
className
)}
style={{
left: position.x,
top: position.y - OFFSET_Y,
transform: 'translate(-50%, -100%)',
}}
>
{content}
</div>,
document.body
)
}
registry/shadcnmaps/map-marker.tsx
'use client'
import { cn } from '@/lib/utils'
import type { KeyboardEvent, ReactNode } from 'react'
import type { MarkerEvent } from './types'
export interface MapMarkerProps {
id: string
x: number
y: number
content: ReactNode
label?: string
disabled?: boolean
className?: string
onClick?: (event: MarkerEvent) => void
onEnter?: (event: MarkerEvent) => void
onLeave?: (event: MarkerEvent) => void
onMove?: (event: MarkerEvent) => void
}
export function MapMarker({
id,
x,
y,
content,
label,
disabled,
className,
onClick,
onEnter,
onLeave,
onMove,
}: MapMarkerProps) {
const handleKeyDown = (event: KeyboardEvent<SVGGElement>) => {
if (disabled) {
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onClick?.({
id,
nativeEvent: event as unknown as MarkerEvent['nativeEvent'],
})
}
}
return (
<g
data-slot='map-marker'
transform={`translate(${x}, ${y})`}
role='button'
tabIndex={disabled ? -1 : 0}
aria-label={label ?? `Map marker ${id}`}
aria-disabled={disabled || undefined}
className={cn(
'cursor-pointer outline-none motion-safe:transition-transform motion-safe:duration-150',
disabled && 'cursor-not-allowed opacity-60',
className
)}
onClick={(nativeEvent) => {
if (disabled) {
return
}
onClick?.({ id, nativeEvent })
}}
onMouseEnter={(nativeEvent) => {
if (disabled) {
return
}
onEnter?.({ id, nativeEvent })
}}
onMouseLeave={(nativeEvent) => {
if (disabled) {
return
}
onLeave?.({ id, nativeEvent })
}}
onMouseMove={(nativeEvent) => {
if (disabled) {
return
}
onMove?.({ id, nativeEvent })
}}
onFocus={(nativeEvent) => {
if (disabled) {
return
}
onEnter?.({
id,
nativeEvent: nativeEvent as unknown as MarkerEvent['nativeEvent'],
})
}}
onBlur={(nativeEvent) => {
if (disabled) {
return
}
onLeave?.({
id,
nativeEvent: nativeEvent as unknown as MarkerEvent['nativeEvent'],
})
}}
onKeyDown={handleKeyDown}
>
{content}
</g>
)
}
registry/shadcnmaps/map-listbox.tsx
'use client'
import { useEffect, useId, useRef } from 'react'
import { useMapContext } from './map-context'
import type { MapRegionData } from './types'
export interface MapListboxProps {
regions: MapRegionData[]
disabledRegions: Set<string>
label: string
onSelect: (region: MapRegionData) => void
}
export function MapListbox({
regions,
disabledRegions,
label,
onSelect,
}: MapListboxProps) {
const { selectedRegion, setSelectedRegion, focusedRegion, setFocusedRegion } =
useMapContext()
const typeaheadRef = useRef('')
const typeaheadTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const optionRefs = useRef(new Map<string, HTMLDivElement>())
const listboxRef = useRef<HTMLDivElement>(null)
const id = useId()
useEffect(() => {
return () => {
if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current)
}
}, [])
const activeRegions = regions.filter((r) => !disabledRegions.has(r.id))
const focusedIndex = activeRegions.findIndex((r) => r.id === focusedRegion)
// The option that should be tabbable (tabIndex=0)
const focusableId =
focusedRegion && !disabledRegions.has(focusedRegion)
? focusedRegion
: selectedRegion && !disabledRegions.has(selectedRegion)
? selectedRegion
: activeRegions[0]?.id
// Move DOM focus when focusedRegion changes
useEffect(() => {
if (!focusedRegion) return
const el = optionRefs.current.get(focusedRegion)
if (el && document.activeElement !== el) {
el.focus()
}
}, [focusedRegion])
// If focusedRegion becomes disabled, move to nearest active region
useEffect(() => {
if (!focusedRegion) return
if (!disabledRegions.has(focusedRegion)) return
// Find nearest active region
const allIndex = regions.findIndex((r) => r.id === focusedRegion)
if (allIndex < 0) {
setFocusedRegion(activeRegions[0]?.id ?? null)
return
}
// Search forward then backward for nearest active
for (let offset = 1; offset < regions.length; offset++) {
const fwd = regions[(allIndex + offset) % regions.length]
if (fwd && !disabledRegions.has(fwd.id)) {
setFocusedRegion(fwd.id)
return
}
}
setFocusedRegion(null)
}, [focusedRegion, disabledRegions, regions, activeRegions, setFocusedRegion])
function moveTo(index: number) {
const region = activeRegions[index]
if (region) setFocusedRegion(region.id)
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const idx = focusedIndex < 0 ? 0 : focusedIndex
switch (event.key) {
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault()
moveTo(focusedIndex < activeRegions.length - 1 ? focusedIndex + 1 : 0)
break
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault()
moveTo(focusedIndex > 0 ? focusedIndex - 1 : activeRegions.length - 1)
break
case 'Home':
event.preventDefault()
moveTo(0)
break
case 'End':
event.preventDefault()
moveTo(activeRegions.length - 1)
break
case 'Enter':
case ' ': {
event.preventDefault()
const region = activeRegions[idx]
if (!region) break
setSelectedRegion((prev) => (prev === region.id ? null : region.id))
onSelect(region)
break
}
case 'Escape': {
event.preventDefault()
const selected = selectedRegion
? activeRegions.find((r) => r.id === selectedRegion)
: undefined
setSelectedRegion(null)
if (selected) onSelect(selected)
break
}
default:
if (event.key.length === 1) {
typeaheadRef.current += event.key.toLowerCase()
if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current)
typeaheadTimer.current = setTimeout(() => {
typeaheadRef.current = ''
}, 500)
const query = typeaheadRef.current
const match = activeRegions.findIndex((r) =>
r.name.toLowerCase().startsWith(query)
)
if (match >= 0) moveTo(match)
}
}
}
function handleBlur(event: React.FocusEvent<HTMLDivElement>) {
// Only clear focus when focus leaves the listbox entirely
if (
!listboxRef.current ||
!event.relatedTarget ||
!listboxRef.current.contains(event.relatedTarget as Node)
) {
setFocusedRegion(null)
}
}
return (
<div
ref={listboxRef}
data-slot='map-listbox'
role='listbox'
aria-label={label}
tabIndex={-1}
className='sr-only'
onKeyDown={handleKeyDown}
onBlur={handleBlur}
>
{regions.map((region) => {
const isDisabled = disabledRegions.has(region.id)
return (
<div
key={region.id}
id={`${id}-${region.id}`}
ref={(el) => {
if (el) {
optionRefs.current.set(region.id, el)
} else {
optionRefs.current.delete(region.id)
}
}}
role='option'
aria-selected={selectedRegion === region.id}
aria-disabled={isDisabled || undefined}
tabIndex={
isDisabled ? undefined : region.id === focusableId ? 0 : -1
}
onFocus={isDisabled ? undefined : () => setFocusedRegion(region.id)}
>
{region.name}
</div>
)
})}
</div>
)
}
registry/shadcnmaps/map-controls.tsx
'use client'
import { cn } from '@/lib/utils'
import { useCallback } from 'react'
import { useMapContext } from './map-context'
export interface MapControlsProps {
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
showPanButtons?: boolean
className?: string
}
const positionClasses: Record<string, string> = {
'top-left': 'top-2 left-2',
'top-right': 'top-2 right-2',
'bottom-left': 'bottom-2 left-2',
'bottom-right': 'bottom-2 right-2',
}
function ControlButton({
onClick,
disabled,
'aria-label': ariaLabel,
children,
}: {
onClick: () => void
disabled?: boolean
'aria-label': string
children: React.ReactNode
}) {
return (
<button
type='button'
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
className='flex h-8 w-8 items-center justify-center rounded-md border border-border bg-background text-foreground shadow-sm transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-50'
>
{children}
</button>
)
}
function PlusIcon() {
return (
<svg
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
>
<path d='M5 12h14' />
<path d='M12 5v14' />
</svg>
)
}
function MinusIcon() {
return (
<svg
width='20'
height='20'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
>
<path d='M5 12h14' />
</svg>
)
}
function HomeIcon() {
return (
<svg
width='18'
height='18'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8' />
<path d='M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z' />
</svg>
)
}
function ChevronUp() {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='m18 15-6-6-6 6' />
</svg>
)
}
function ChevronDown() {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='m6 9 6 6 6-6' />
</svg>
)
}
function ChevronLeft() {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='m15 18-6-6 6-6' />
</svg>
)
}
function ChevronRight() {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='m9 18 6-6-6-6' />
</svg>
)
}
export function MapControls({
position = 'top-right',
showPanButtons = true,
className,
}: MapControlsProps) {
const { zoomIn, zoomOut, resetZoom, zoomState, zoomConfig, setZoomState } =
useMapContext()
const panUp = useCallback(() => {
setZoomState((prev) => ({
...prev,
translateY: prev.translateY + zoomConfig.panStep,
}))
}, [setZoomState, zoomConfig.panStep])
const panDown = useCallback(() => {
setZoomState((prev) => ({
...prev,
translateY: prev.translateY - zoomConfig.panStep,
}))
}, [setZoomState, zoomConfig.panStep])
const panLeft = useCallback(() => {
setZoomState((prev) => ({
...prev,
translateX: prev.translateX + zoomConfig.panStep,
}))
}, [setZoomState, zoomConfig.panStep])
const panRight = useCallback(() => {
setZoomState((prev) => ({
...prev,
translateX: prev.translateX - zoomConfig.panStep,
}))
}, [setZoomState, zoomConfig.panStep])
const atMaxZoom = zoomState.scale >= zoomConfig.maxZoom
const atMinZoom = zoomState.scale <= zoomConfig.minZoom
return (
<div
data-slot='map-controls'
className={cn(
'absolute z-10 flex flex-col items-center gap-1',
positionClasses[position],
className
)}
>
<ControlButton onClick={zoomIn} disabled={atMaxZoom} aria-label='Zoom in'>
<PlusIcon />
</ControlButton>
<ControlButton
onClick={zoomOut}
disabled={atMinZoom}
aria-label='Zoom out'
>
<MinusIcon />
</ControlButton>
<ControlButton onClick={resetZoom} aria-label='Reset zoom'>
<HomeIcon />
</ControlButton>
{showPanButtons ? (
<>
<div className='my-0.5 h-px w-6 bg-border' />
<ControlButton onClick={panUp} aria-label='Pan up'>
<ChevronUp />
</ControlButton>
<div className='flex gap-1'>
<ControlButton onClick={panLeft} aria-label='Pan left'>
<ChevronLeft />
</ControlButton>
<ControlButton onClick={panRight} aria-label='Pan right'>
<ChevronRight />
</ControlButton>
</div>
<ControlButton onClick={panDown} aria-label='Pan down'>
<ChevronDown />
</ControlButton>
</>
) : null}
</div>
)
}
registry/shadcnmaps/types.ts
import type {
FocusEvent,
KeyboardEvent,
MouseEvent,
ReactNode,
TouchEvent,
} from 'react'
/** Unique identifier for the region (e.g., "AL", "CA", "FR-IDF") */
export interface MapRegionData {
id: string
/** Human-readable display name */
name: string
/** SVG path d attribute */
path: string
/** Short abbreviation (e.g., "AL") */
abbreviation?: string
/** X coordinate for the abbreviation label within the SVG viewBox */
labelX?: number
/** Y coordinate for the abbreviation label within the SVG viewBox */
labelY?: number
/** Arbitrary metadata attached to the region, passed back in event callbacks */
metadata?: Record<string, unknown>
}
export interface MapData {
/** Unique map identifier (e.g., "usa", "world", "france") */
id: string
/** Human-readable map name */
name: string
/** SVG viewBox string (e.g., "0 0 930 590") */
viewBox: string
/** Array of region path data */
regions: MapRegionData[]
}
export interface MapMarkerData {
/** Unique identifier */
id: string
/** X coordinate in the map's SVG coordinate system */
x: number
/** Y coordinate in the map's SVG coordinate system */
y: number
/** SVG content to render at (x, y) */
content: ReactNode
/** Tooltip content shown on hover/click */
tooltipContent?: ReactNode
/** Accessible label */
label?: string
/** When true, the marker is not interactive */
disabled?: boolean
}
export interface RegionEvent {
/** The region's static data from MapRegionData */
region: MapRegionData
/** The native React synthetic event (null for programmatic selection via keyboard listbox) */
nativeEvent:
| MouseEvent<SVGPathElement>
| TouchEvent<SVGPathElement>
| FocusEvent<SVGPathElement>
| KeyboardEvent<SVGPathElement>
| null
}
export interface MarkerEvent {
/** The marker's id */
id: string
/** The native React synthetic event */
nativeEvent:
| MouseEvent<SVGGElement>
| TouchEvent<SVGGElement>
| FocusEvent<SVGGElement>
| KeyboardEvent<SVGGElement>
}
export interface RegionOverride {
id: string
/** Override display name (e.g. for localization) */
name?: string
/** Override abbreviation label */
abbreviation?: string
/** Override label X position */
labelX?: number
/** Override label Y position */
labelY?: number
/** Attach arbitrary metadata */
metadata?: Record<string, unknown>
/** Extra CSS classes for fill/stroke */
className?: string
/** Extra CSS classes for the abbreviation label */
labelClassName?: string
/** Custom tooltip content */
tooltipContent?: ReactNode
/** Disable interaction */
disabled?: boolean
}
export interface ZoomState {
scale: number
translateX: number
translateY: number
}
export interface ZoomConfig {
minZoom: number
maxZoom: number
zoomStep: number
panStep: number
}