Velocity X Components by Aidxn Design

Custom Framer Component · Free, unlicensed

Service Area Map

A pulsing, interactive east-coast Australia service-area map with 15 real town markers, orthographic projection, and cycle/hover/hidden label modes.

maplocationsgeographysvg

This is a standalone Framer Code File — not installed with the CLI, not gated behind a subscription. Copy the whole file and paste it into Framer via Assets → Code → Create Code File.

Property controls

These are Framer's own addPropertyControls fields, which show up as the right-hand properties panel once this is pasted into Framer. They are not React props and have nothing to do with this site's licensed component API.

Panel label Control key Control type
Map dots dotColor Color
Highlight accentColor Color
Text textColor Color
Font font Font
Labels labels Enum
Label hold cycleSeconds Number
Pulse on open animate Boolean
Pulse length pulseSpeed Number
Dot density density Number
Dot size dotSize Number
Marker size markerSize Number
Glow glow Number
QLD–NSW border showBorder Boolean
Caption showCaption Boolean
Radius radius Number

Component source

The complete, unmodified file — copy the whole thing.

tsx
import * as React from "react"
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"

export const META = {
    slug: "service-area-map",
    title: "Service Area Map",
    description: "A pulsing, interactive east-coast Australia service-area map with 15 real town markers, orthographic projection, and cycle/hover/hidden label modes.",
    tags: ["map", "locations", "geography", "svg"],
    category: "custom-framer",
}

// Paste this entire file into a new Framer Code Component.
// Coastline: Natural Earth 1:50m, public domain, rounded to 0.001°.
// https://github.com/nvkelso/natural-earth-vector
// Markers represent approximate town centres, not service boundaries.
type Coordinate = readonly [number, number]
// Default palette — swap via props for your own brand.
const DEFAULTS = {
    harbourNavy: "#214E68",
    slateBlue: "#577C8D",
} as const
type Location = {
    name: string
    state: "QLD" | "NSW"
    coordinate: Coordinate
    label: Coordinate
    side: "left" | "right"
}

export const SERVICE_LOCATIONS: readonly Location[] = [
    { name: "Maryborough", state: "QLD", coordinate: [152.7049, -25.5377], label: [520, 182], side: "right" },
    { name: "Gympie", state: "QLD", coordinate: [152.665, -26.19], label: [520, 226], side: "right" },
    { name: "Sunshine Coast", state: "QLD", coordinate: [153.066, -26.6516], label: [520, 270], side: "right" },
    { name: "Brisbane", state: "QLD", coordinate: [153.0251, -27.4698], label: [520, 314], side: "right" },
    { name: "Gatton", state: "QLD", coordinate: [152.2763, -27.5581], label: [275, 326], side: "left" },
    { name: "Toowoomba", state: "QLD", coordinate: [151.9507, -27.5598], label: [275, 282], side: "left" },
    { name: "Ipswich", state: "QLD", coordinate: [152.7608, -27.6144], label: [275, 370], side: "left" },
    { name: "Logan", state: "QLD", coordinate: [153.11, -27.64], label: [520, 358], side: "right" },
    { name: "Warwick", state: "QLD", coordinate: [152.0344, -28.219], label: [250, 420], side: "left" },
    { name: "Texas", state: "QLD", coordinate: [151.168, -28.855], label: [225, 464], side: "left" },
    { name: "Grafton", state: "NSW", coordinate: [152.938, -29.69], label: [520, 476], side: "right" },
    { name: "Glen Innes", state: "NSW", coordinate: [151.738, -29.737], label: [250, 526], side: "left" },
    { name: "Armidale", state: "NSW", coordinate: [151.67, -30.504], label: [250, 580], side: "left" },
    { name: "Newcastle", state: "NSW", coordinate: [151.7817, -32.9283], label: [475, 704], side: "right" },
    { name: "Penrith", state: "NSW", coordinate: [150.696, -33.751], label: [440, 786], side: "right" },
]

const COAST: readonly Coordinate[] = [
    [150.783,-23.177],[150.843,-23.458],[150.931,-23.532],[150.989,-23.602],[151.088,-23.696],[151.154,-23.784],[151.236,-23.825],[151.501,-24.012],[151.575,-24.034],[151.691,-24.038],[151.832,-24.123],[151.903,-24.201],[152.055,-24.494],[152.13,-24.598],[152.282,-24.699],[152.353,-24.733],[152.456,-24.802],[152.493,-24.904],[152.502,-24.964],[152.563,-25.072],[152.654,-25.202],[152.789,-25.274],[152.913,-25.432],[152.921,-25.689],[152.985,-25.816],[153.028,-25.87],[153.125,-25.923],[153.165,-25.964],[153.084,-26.304],[153.162,-26.983],[153.117,-27.194],[153.198,-27.405],[153.386,-27.769],[153.428,-27.898],[153.455,-28.048],[153.576,-28.241],[153.569,-28.533],[153.617,-28.673],[153.605,-28.854],[153.462,-29.05],[153.348,-29.29],[153.347,-29.497],[153.272,-29.892],[153.224,-29.999],[153.188,-30.164],[153.031,-30.563],[153.024,-30.72],[153.048,-30.907],[153.022,-31.087],[152.982,-31.209],[152.944,-31.435],[152.786,-31.786],[152.559,-32.046],[152.545,-32.243],[152.517,-32.33],[152.47,-32.439],[152.331,-32.558],[152.247,-32.609],[152.216,-32.678],[152.137,-32.678],[152.135,-32.7],[152.188,-32.722],[152.164,-32.757],[151.954,-32.82],[151.813,-32.901],[151.668,-33.099],[151.608,-33.202],[151.53,-33.301],[151.484,-33.347],[151.463,-33.397],[151.432,-33.522],[151.358,-33.544],[151.292,-33.581],[151.323,-33.699],[151.288,-33.835],[151.28,-33.927],[151.245,-33.985],[151.202,-33.964],[151.168,-33.973],[151.125,-34.005],[151.191,-34.015],[151.232,-34.03],[151.09,-34.162],[150.96,-34.297],[150.927,-34.387],[150.871,-34.499],[150.822,-34.749],[150.781,-34.892],[150.809,-34.994],[150.805,-35.013],[150.775,-35.02],[150.756,-35.007],[150.697,-35.042],[150.681,-35.077],[150.706,-35.12],[150.722,-35.135],[150.715,-35.155],[150.69,-35.178],[150.634,-35.178],[150.567,-35.214],
]
// K'gari and the two large Moreton Bay islands.
const ISLANDS: readonly (readonly Coordinate[])[] = [
    [[153.02,-24.70],[153.25,-24.99],[153.36,-25.48],[153.08,-25.81],[152.94,-25.57],[153.04,-25.31]],
    [[153.37,-27.03],[153.47,-27.08],[153.43,-27.35],[153.35,-27.30]],
    [[153.46,-27.39],[153.54,-27.43],[153.47,-27.72],[153.39,-27.65]],
]
// Wider Natural Earth context: northern Queensland through the Victorian coast.
const EXTENDED_COAST: readonly Coordinate[] = [
    [143.562,-13.764],[143.922,-14.548],[144.564,-14.171],[144.895,-14.594],
    [145.375,-14.985],[145.272,-15.428],[145.485,-16.286],[145.637,-16.785],
    [145.889,-16.907],[146.160,-17.762],[146.064,-18.280],[146.387,-18.958],
    [147.471,-19.481],[148.178,-19.956],[148.848,-20.391],[148.717,-20.633],
    [149.289,-21.261],[149.678,-22.343],[150.077,-22.123],[150.483,-22.556],
    [150.727,-22.402], ...COAST,
    [150.328,-35.672],[150.075,-36.420],[149.946,-37.109],[149.997,-37.425],
    [149.424,-37.773],[148.305,-37.809],[147.382,-38.219],[146.922,-38.607],
    [146.318,-39.036],[145.490,-38.594],[144.877,-38.417],[145.032,-37.896],
    [144.486,-38.085],[143.610,-38.809],[142.745,-38.538],[142.178,-38.380],
    [141.607,-38.309],[140.639,-38.019],[139.992,-37.403],[139.807,-36.644],
    [139.574,-36.138],[139.083,-35.733],[138.121,-35.612],[138.449,-35.127],
    [138.208,-34.385],[137.719,-35.077],[136.829,-35.261],[137.352,-34.707],
    [137.504,-34.130],[137.890,-33.640],[137.810,-32.900],[136.997,-33.753],
    [136.372,-34.095],[135.989,-34.890],[135.208,-34.479],[135.239,-33.948],
    [134.613,-33.223],[134.086,-32.848],[134.274,-32.617],[132.991,-32.011],
    [132.288,-31.983],[131.326,-31.496],[130,-31.59],
]
const LAND: readonly (readonly Coordinate[])[] = [
    [[130,-13.764], ...EXTENDED_COAST], ...ISLANDS,
]
// Simplified shared QLD/NSW edges from the project's suburb boundary dataset.
// Follows the river and range boundary; not a straight 29° line to the coast.
const STATE_BORDER: readonly Coordinate[] = [
    [141,-28.999],[142,-28.999],[143,-28.999],[144,-28.999],[145,-28.999],
    [146,-28.999],[147,-28.999],[148,-28.999],[148.958,-28.999],
    [149.034,-28.953],[149.098,-28.832],[149.187,-28.795],[149.334,-28.698],
    [149.508,-28.576],[149.588,-28.574],[149.744,-28.618],[149.946,-28.613],
    [150.166,-28.554],[150.293,-28.538],[150.483,-28.662],[150.671,-28.654],
    [150.815,-28.658],[150.952,-28.737],[151.023,-28.752],[151.039,-28.837],
    [151.175,-28.881],[151.234,-28.905],[151.272,-28.973],[151.279,-29.099],
    [151.349,-29.176],[151.453,-29.125],[151.538,-28.980],[151.637,-28.922],
    [151.729,-28.868],[151.773,-28.938],[151.896,-28.913],[151.995,-28.899],
    [152.036,-28.858],[152.037,-28.765],[152.076,-28.708],[151.996,-28.631],
    [151.993,-28.526],[152.115,-28.463],[152.217,-28.449],[152.300,-28.374],
    [152.392,-28.362],[152.417,-28.297],[152.464,-28.259],[152.532,-28.263],
    [152.526,-28.306],[152.585,-28.328],[152.640,-28.312],[152.734,-28.353],
    [152.839,-28.322],[152.945,-28.335],[153.109,-28.356],[153.161,-28.280],
    [153.188,-28.248],[153.297,-28.234],[153.436,-28.187],[153.486,-28.157],
    [153.552,-28.164],
]
const RAD = Math.PI / 180

// Orthographic projection: a fixed, close-up view of the Earth's surface.
// Both the dot field and service markers use this exact projection.
function project([longitude, latitude]: Coordinate): Coordinate {
    const lon = (longitude - 149) * RAD
    const lat = latitude * RAD
    const centre = -29.5 * RAD
    return [
        320 + 2600 * Math.cos(lat) * Math.sin(lon),
        460 - 2600 * (Math.cos(centre) * Math.sin(lat) - Math.sin(centre) * Math.cos(lat) * Math.cos(lon)),
    ]
}

function contains([x, y]: Coordinate, polygon: readonly Coordinate[]): boolean {
    let inside = false
    for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
        const [xi, yi] = polygon[i]
        const [xj, yj] = polygon[j]
        if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside
    }
    return inside
}

function makeDots(density: number) {
    const dots: { x: number; y: number; opacity: number }[] = []
    const step = 0.23 / Math.max(0.65, Math.min(1.8, density))
    let row = 0
    for (let lat = -40; lat <= -14; lat += step) {
        const longitudeStep = step / Math.cos(lat * RAD)
        for (let lon = 138 + (row % 2) * longitudeStep / 2; lon <= 154; lon += longitudeStep) {
            if (!LAND.some(polygon => contains([lon, lat], polygon))) continue
            const [x, y] = project([lon, lat])
            if (x < 10 || x > 710 || y < 35 || y > 865) continue
            const light = Math.max(0, 1 - Math.abs(lon - 152.5) / 8)
            dots.push({ x: +x.toFixed(2), y: +y.toFixed(2), opacity: +(0.18 + light * 0.7).toFixed(2) })
        }
        row++
    }
    return dots
}

function line(points: readonly Coordinate[]) {
    return points.map((point, i) => {
        const [x, y] = project(point)
        return `${i ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`
    }).join(" ")
}

const PROJECTED_LOCATIONS = SERVICE_LOCATIONS.map(location => ({ ...location, point: project(location.coordinate) }))

// Shared by the React component and the server-free preview. Updates only
// marker attributes: the thousands of static land dots never re-render.
export function startLocationCycle(root: HTMLElement, options: { mode: string; seconds: number }) {
    const markers = Array.from(root.querySelectorAll<SVGGElement>(".ec-location"))
    const media = window.matchMedia("(prefers-reduced-motion: reduce)")
    let automatic = 0
    let hovered: number | null = null
    let focused: number | null = null
    let pinned: number | null = null
    let visible = true
    let timer: number | undefined
    let bag: number[] = []
    const cleanup: (() => void)[] = []
    const listen = (target: EventTarget, type: string, handler: EventListener) => {
        target.addEventListener(type, handler)
        cleanup.push(() => target.removeEventListener(type, handler))
    }
    const next = () => {
        if (!bag.length) {
            bag = markers.map((_, i) => i).filter(i => i !== automatic)
            for (let i = bag.length - 1; i > 0; i--) {
                const j = Math.floor(Math.random() * (i + 1))
                ;[bag[i], bag[j]] = [bag[j], bag[i]]
            }
        }
        let candidate = bag.pop()
        while (candidate === automatic && bag.length) candidate = bag.pop()
        if (candidate !== undefined && candidate !== automatic) automatic = candidate
        else if (markers.length > 1) automatic = (automatic + 1) % markers.length
    }
    const update = () => {
        window.clearTimeout(timer)
        const active = options.mode === "none" ? null : hovered ?? focused ?? pinned ?? (options.mode === "hover" ? null : automatic)
        markers.forEach((marker, index) => {
            marker.dataset.active = String(index === active)
            marker.setAttribute("aria-pressed", String(index === pinned))
        })
        root.dataset.paused = String(!visible || document.hidden)
        if (options.mode !== "hover" && options.mode !== "none" && hovered === null && focused === null && pinned === null && visible && !document.hidden && !media.matches && markers.length > 1) {
            timer = window.setTimeout(() => { next(); update() }, Math.max(1.5, options.seconds) * 1000)
        }
    }
    markers.forEach((marker, index) => {
        listen(marker, "pointerenter", event => {
            if ((event as PointerEvent).pointerType === "touch") return
            hovered = index
            automatic = index
            update()
        })
        listen(marker, "pointerleave", () => { hovered = null; update() })
        listen(marker, "focus", () => {
            if (marker.matches(":focus-visible")) { focused = index; automatic = index; update() }
        })
        listen(marker, "blur", () => { focused = null; update() })
        listen(marker, "click", () => { pinned = hovered === null ? (pinned === index ? null : index) : null; automatic = hovered ?? index; update() })
        listen(marker, "keydown", event => {
            const key = event as KeyboardEvent
            if (key.key === "Enter" || key.key === " ") {
                key.preventDefault()
                pinned = pinned === index ? null : index
                automatic = index
                update()
            }
            if (key.key === "Escape") { pinned = null; focused = null; hovered = null; marker.blur(); update() }
        })
    })
    // Resolve the closest dot when nearby towns' pointer targets overlap.
    listen(root, "pointermove", event => {
        const pointer = event as PointerEvent
        if (pointer.pointerType === "touch") return
        if ((event.target as Element).closest(".ec-label-wrap")) return
        const svg = root.querySelector("svg")
        const matrix = svg?.getScreenCTM()
        if (!svg || !matrix) return
        const point = svg.createSVGPoint()
        point.x = pointer.clientX
        point.y = pointer.clientY
        const local = point.matrixTransform(matrix.inverse())
        let closest: number | null = null
        let distance = 18
        markers.forEach((marker, index) => {
            const d = Math.hypot(local.x - Number(marker.dataset.x), local.y - Number(marker.dataset.y))
            if (d < distance) { distance = d; closest = index }
        })
        if (hovered !== closest) { hovered = closest; if (closest !== null) automatic = closest; update() }
    })
    listen(root, "pointerleave", () => { hovered = null; update() })
    listen(root, "click", event => {
        if (!(event.target as Element).closest(".ec-location")) { pinned = null; update() }
    })
    listen(document, "visibilitychange", update)
    listen(media, "change", update)
    const observer = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver(([entry]) => {
        visible = entry.isIntersecting
        update()
    })
    observer?.observe(root)
    update()
    return () => { window.clearTimeout(timer); observer?.disconnect(); cleanup.forEach(remove => remove()) }
}

const CSS = `
.ec-service-map { container-type: inline-size; isolation: isolate; }
.ec-service-map .ec-location { outline: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.ec-service-map .ec-label { font-size: var(--ec-label-size, 15px); }
.ec-service-map .ec-leader, .ec-service-map .ec-label-wrap { opacity: 0; pointer-events: none; transition: opacity .18s ease; }
.ec-service-map .ec-location[data-active="true"] .ec-label-wrap { opacity: 1; pointer-events: auto; transition-delay: .18s; }
.ec-service-map .ec-location[data-active="true"] .ec-leader { opacity: .65; transition-delay: .18s; }
.ec-service-map .ec-focus { opacity: 0; }
.ec-service-map .ec-location:focus-visible .ec-focus { opacity: 1; }
.ec-service-map .ec-halo { opacity: .15; transition: opacity .18s; }
.ec-service-map .ec-location[data-active="true"] .ec-halo { opacity: 1; }
.ec-service-map[data-labels="none"] .ec-label-wrap,
.ec-service-map[data-labels="none"] .ec-leader { display: none; }
.ec-service-map .ec-pulse { opacity: 0; transform-box: fill-box; transform-origin: center; pointer-events: none; }
.ec-service-map .ec-location[data-active="true"] .ec-pulse { animation: ec-service-map-pulse var(--ec-speed) cubic-bezier(.2,.65,.4,1) .18s 1 both; }
.ec-service-map[data-static="true"] .ec-location .ec-pulse { animation: none; opacity: 0; }
.ec-service-map[data-paused="true"] .ec-location .ec-pulse { animation-play-state: paused; }
@keyframes ec-service-map-pulse {
    0% { transform: scale(.65); opacity: .65; }
    80%,100% { transform: scale(4.5); opacity: 0; }
}
@container (max-width: 480px) {
    .ec-service-map .ec-label { font-size: calc(var(--ec-label-size, 15px) * 1.4); }
    .ec-service-map .ec-caption { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
    .ec-service-map .ec-location[data-active="true"] .ec-pulse { animation: none; opacity: 0; }
    .ec-service-map .ec-label-wrap, .ec-service-map .ec-leader { transition: none !important; }
}
`

type Props = {
    dotColor?: string
    accentColor?: string
    textColor?: string
    density?: number
    dotSize?: number
    markerSize?: number
    pulseSpeed?: number
    animate?: boolean
    labels?: "cycle" | "hover" | "none"
    cycleSeconds?: number
    font?: React.CSSProperties
    showBorder?: boolean
    showCaption?: boolean
    glow?: number
    radius?: number
    style?: React.CSSProperties
}

/**
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight any
 * @framerIntrinsicWidth 720
 * @framerIntrinsicHeight 900
 */
export default function ServiceAreaMap({
    dotColor = DEFAULTS.slateBlue, accentColor = DEFAULTS.harbourNavy,
    textColor = DEFAULTS.harbourNavy, density = 1.25, dotSize = 1.45, markerSize = 3.4,
    pulseSpeed = 1.2, animate = true, labels = "cycle", cycleSeconds = 3.5, showBorder = true,
    showCaption = true, glow = 0.65, radius = 0, font, style,
}: Props) {
    const id = `ec-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`
    const root = React.useRef<HTMLDivElement>(null)
    const isStatic = useIsStaticRenderer()
    const dots = React.useMemo(() => makeDots(density), [density])

    React.useEffect(() => {
        if (!root.current || isStatic) return
        return startLocationCycle(root.current, { mode: labels, seconds: cycleSeconds })
    }, [isStatic, labels, cycleSeconds])

    return (
        <div ref={root} className="ec-service-map" data-static={isStatic || !animate}
            data-labels={labels}
            style={{ width: "100%", height: "100%", borderRadius: radius,
                overflow: "hidden", fontFamily: 'Geist, Arial, sans-serif', fontWeight: 400,
                ...font, ...style, background: "transparent", "--ec-accent": accentColor, "--ec-speed": `${Math.max(.4, pulseSpeed)}s`,
                "--ec-label-size": typeof font?.fontSize === "number" ? `${font.fontSize}px` : font?.fontSize ?? "15px",
            } as React.CSSProperties}>
            <style>{CSS}</style>
            <svg viewBox="0 0 720 900" width="100%" height="100%" fill="none"
                role="group" aria-labelledby={`${id}-title ${id}-desc`} style={{ display: "block" }}>
                <title id={`${id}-title`}>East coast Australia service locations</title>
                <desc id={`${id}-desc`}>15 locations in Queensland and New South Wales, from Maryborough to Penrith. Select a marker or label to highlight it. Markers indicate town centres, not coverage boundaries.</desc>
                <defs>
                    <radialGradient id={`${id}-fade`} cx="58%" cy="49%" r="63%">
                        <stop offset=".38" stopColor="white" />
                        <stop offset="1" stopColor="black" />
                    </radialGradient>
                    <mask id={`${id}-mask`}><rect width="720" height="900" fill={`url(#${id}-fade)`} /></mask>
                    <radialGradient id={`${id}-halo`}>
                        <stop stopColor={accentColor} stopOpacity={0.8 * glow} />
                        <stop offset="1" stopColor={accentColor} stopOpacity="0" />
                    </radialGradient>
                </defs>
                <g aria-hidden="true" pointerEvents="none">
                    <g mask={`url(#${id}-mask)`}>
                        <g fill={dotColor}>
                            {dots.map((dot, i) => <circle key={i} cx={dot.x} cy={dot.y} r={dotSize} opacity={dot.opacity} />)}
                        </g>
                        <path d={line(EXTENDED_COAST)} stroke={dotColor} strokeWidth=".85" opacity=".4" />
                        {showBorder ? <path className="ec-state-border" d={line(STATE_BORDER)} stroke={textColor} strokeWidth="1.2" strokeDasharray="4 4" opacity=".7" /> : null}
                    </g>
                    <text x="170" y="398" fill={textColor} opacity=".5" fontSize="12" letterSpacing="2">QLD</text>
                    <text x="170" y="474" fill={textColor} opacity=".5" fontSize="12" letterSpacing="2">NSW</text>
                </g>
                {PROJECTED_LOCATIONS.map((location, index) => {
                    const [x, y] = location.point
                    const left = location.side === "left"
                    const lx = x + (left ? -38 : 38)
                    const ly = y - 24
                    const end = lx + (left ? 12 : -12)
                    const elbow = end + (left ? 32 : -22)
                    return (
                        <g key={location.name} className="ec-location" role="button" tabIndex={0}
                            aria-label={`${location.name}, ${location.state}`} aria-pressed={false}
                            data-x={x} data-y={y}
                            data-active={index === 0 && labels !== "none" && labels !== "hover"}>
                            <path className="ec-leader" d={`M${x},${y} L${elbow},${ly} H${end}`} stroke={accentColor} strokeWidth=".85" pointerEvents="none" />
                            <g transform={`translate(${x},${y})`}>
                                <circle className="ec-halo" r={markerSize * 6} fill={`url(#${id}-halo)`} pointerEvents="none" />
                                <circle className="ec-pulse" r={markerSize + 1.5} stroke={accentColor} strokeWidth=".65" />
                                <circle r={markerSize + 4} stroke={accentColor} strokeWidth=".7" opacity=".45" pointerEvents="none" />
                                <circle className="ec-focus" r={markerSize + 8} stroke={accentColor} strokeWidth="1.5" pointerEvents="none" />
                                <circle r={markerSize} fill={accentColor} />
                                <circle r={markerSize * .38} fill="white" opacity=".9" pointerEvents="none" />
                                <circle r="11" fill="transparent" />
                            </g>
                            <g className="ec-label-wrap">
                                <rect x={left ? lx - 170 : lx - 5} y={ly - 20} width="175" height="40" fill="transparent" />
                                <text className="ec-label" x={lx} y={ly} dy=".35em" textAnchor={left ? "end" : "start"} fill={textColor}>{location.name}</text>
                            </g>
                        </g>
                    )
                })}
                {showCaption ? <g aria-hidden="true" pointerEvents="none" fill={textColor}>
                    <text className="ec-caption" x="40" y="47" fontSize="11" letterSpacing="2.4" opacity=".58">East coast / Australia</text>
                    <circle cx="44" cy="857" r="3" fill={accentColor} />
                    <text className="ec-caption" x="58" y="861" fontSize="12" opacity=".6">15 service locations</text>
                    <text className="ec-caption" x="680" y="861" textAnchor="end" fontSize="11" letterSpacing="1.6" opacity=".4">QLD + NSW</text>
                </g> : null}
            </svg>
        </div>
    )
}

addPropertyControls(ServiceAreaMap, {
    dotColor: { type: ControlType.Color, title: "Map dots", defaultValue: DEFAULTS.slateBlue },
    accentColor: { type: ControlType.Color, title: "Highlight", defaultValue: DEFAULTS.harbourNavy },
    textColor: { type: ControlType.Color, title: "Text", defaultValue: DEFAULTS.harbourNavy },
    font: { type: ControlType.Font, title: "Font", controls: "extended", defaultValue: { fontFamily: "Geist", fontSize: "15px", fontWeight: 400, letterSpacing: "0em", lineHeight: "1.2em" } },
    labels: { type: ControlType.Enum, title: "Labels", options: ["cycle", "hover", "none"], optionTitles: ["Random cycle", "On interaction", "Hidden"], defaultValue: "cycle" },
    cycleSeconds: { type: ControlType.Number, title: "Label hold", defaultValue: 3.5, min: 1.5, max: 12, step: .5, unit: "s", hidden: props => props.labels !== "cycle" },
    animate: { type: ControlType.Boolean, title: "Pulse on open", defaultValue: true },
    pulseSpeed: { type: ControlType.Number, title: "Pulse length", defaultValue: 1.2, min: .4, max: 3, step: .1, unit: "s", hidden: props => !props.animate },
    density: { type: ControlType.Number, title: "Dot density", defaultValue: 1.25, min: .65, max: 1.8, step: .05 },
    dotSize: { type: ControlType.Number, title: "Dot size", defaultValue: 1.45, min: .5, max: 3, step: .05 },
    markerSize: { type: ControlType.Number, title: "Marker size", defaultValue: 3.4, min: 2, max: 6, step: .1 },
    glow: { type: ControlType.Number, title: "Glow", defaultValue: .65, min: 0, max: 1, step: .05 },
    showBorder: { type: ControlType.Boolean, title: "QLD–NSW border", defaultValue: true },
    showCaption: { type: ControlType.Boolean, title: "Caption", defaultValue: true },
    radius: { type: ControlType.Number, title: "Radius", defaultValue: 0, min: 0, max: 80, step: 1, description: "Component by [AIDXN DESIGN](https://aidxn.com)." },
})
  1. 1. In Framer: Assets → Code → Create Code File.
  2. 2. Delete the placeholder and paste this entire file — it already carries its default export and addPropertyControls.