shadimage
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
}

More from shadcnmaps

Browse all components in this library →