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.
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. In Framer: Assets → Code → Create Code File.
- 2. Delete the placeholder and paste this entire file — it already carries its default export and addPropertyControls.