Add a store locator to Framer
For Framer there is our code component to copy. You add it to your project once, drag it onto the page and enter your map. Without the component, Framer's Embed component works too.
Code component to copyOne file that you paste into Framer as a code component. The Embed component keeps working.
Submitted to the Framer Marketplace, in reviewOnce approved, you install the component with one click. Until then, copy the code from this page.
My Store Locator for Framer
Works with: Framer (React/TypeScript code component, one file)
Download .tsx fileFile MyStoreLocator-1.0.0.tsx · SHA-256 42ad2006e95cda41c6cec0a1956a2c56745219006926dde193ab45abc64094fa
What you get
A location map with list, search and filters in your Framer layout. You manage locations in My Store Locator. With the code component you set the map, height, language and two-click consent in the properties panel on the right, without editing HTML.
Before you start
- A My Store Locator account (sign up free)
- A published map (status "Live")
- The map token or your map's address from the Embed Studio in the app
- A Framer project you can edit
Add the code component
- Copy the code below with Copy code (or download the file above and open it in a text editor).
- In Framer, open Assets on the left, click + next to Code and choose New component. Name the file
MyStoreLocator. - Replace the entire content of the new file with the copied code and save (Ctrl + S or Cmd + S).
- Drag MyStoreLocator from Assets → Code onto your page and set its width to Fill.
- In the properties panel on the right, paste your map token into Map token. Your map's address (
https://app.my-store-locator.com/#/m/…) or the embed code work too - the component reads the token from them. - Publish the page and check the map on the live site.
// My Store Locator – Framer code component.
// Paste this file into a Framer code file (Assets → Code → New file) or use the Marketplace listing.
// Renders the official widget (https://app.my-store-locator.com/widget.js) – the map itself stays in My Store Locator.
// Strict consent: with "Click to load" on, the published site shows a local placeholder and requests widget.js only after the click.
import { addPropertyControls, ControlType, RenderTarget } from "framer"
import { useEffect, useRef, useState, type CSSProperties } from "react"
// ---- shared embed helpers (same code as wix/…/msl-embed.ts and webflow/src/msl-embed.ts) ----
const APP = 'https://app.my-store-locator.com';
const WIDGET = `${APP}/widget.js`;
const TOKEN_RE = /^[A-Za-z0-9_-]{6,64}$/;
export function extractToken(input: unknown): string | null {
const s = String(input ?? '').trim();
if (!s) return null;
const m = s.match(/data-token\s*=\s*["']?([A-Za-z0-9_-]{6,64})/) || s.match(/#\/m\/([A-Za-z0-9_-]{6,64})/) || s.match(/[?&]msl_token=([A-Za-z0-9_-]{6,64})/);
const t = m ? m[1] : s;
return TOKEN_RE.test(t) ? t : null;
}
interface EmbedOptions {
token: string;
height?: string; // "auto" | "640px" | "80vh" | "100%"
lang?: string; // "de" | "en" | "" (map default)
consent?: boolean; // strict click-to-load (default true)
lazy?: boolean;
layout?: string; // "split" (with height auto)
offset?: number; // sticky header px (with height auto)
}
/** Placeholder texts – identical to widget.js. */
const CONSENT_TEXT = {
de: ['Diese Karte wird von my-store-locator.com geladen (eigener Kartenserver in Deutschland). Dabei wird Ihre IP-Adresse an diesen Server übertragen.', 'Karte laden'],
en: ['This map is loaded from my-store-locator.com (own map server in Germany). Your IP address is transferred to this server.', 'Load map'],
};
/** de when data-lang is de, en when en, otherwise the page's <html lang> (de… → German, everything else English) – like widget.js. */
function consentLang(lang?: string, doc: Document = document): 'de' | 'en' {
if (lang === 'de' || lang === 'en') return lang;
return /^de/i.test(doc.documentElement.lang || '') ? 'de' : 'en';
}
let seq = 0;
/** The widget container (no data-consent – consent is handled here before widget.js is loaded). */
function createContainer(o: EmbedOptions, idPrefix: string, doc: Document = document): HTMLDivElement {
const el = doc.createElement('div');
el.id = `${idPrefix}-${++seq}`;
el.className = 'msl-map';
el.setAttribute('data-mystorelocator', '');
el.setAttribute('data-token', o.token);
const height = o.height || '640px';
el.setAttribute('data-height', height);
if (o.lang === 'de' || o.lang === 'en') el.setAttribute('data-lang', o.lang);
if (o.lazy !== false) el.setAttribute('data-lazy', 'true');
if (height === 'auto' && o.layout === 'split') el.setAttribute('data-layout', 'split');
if (height === 'auto' && o.offset && o.offset > 0) el.setAttribute('data-offset', String(Math.round(o.offset)));
if (height === '100%') el.style.height = '100%';
return el;
}
interface MslApi { mount(el: Element): void; mountAll(root?: Element): void }
/** Loads widget.js once and mounts `el` (new widget: mount API + MutationObserver; old widget: one script per container). */
function loadWidget(el: HTMLElement, doc: Document = document): void {
const win = doc.defaultView as (Window & { MyStoreLocator?: MslApi }) | null;
if (win && win.MyStoreLocator && typeof win.MyStoreLocator.mount === 'function') {
win.MyStoreLocator.mount(el);
return;
}
const pending = doc.querySelector('script[data-msl-widget]:not([data-loaded])');
if (pending) return; // still loading: its first scan finds `el`
const s = doc.createElement('script');
s.src = WIDGET;
s.async = true;
s.setAttribute('data-msl-widget', '');
s.addEventListener('load', () => s.setAttribute('data-loaded', ''));
doc.body.appendChild(s);
}
/** Renders the map into `host`: placeholder first when consent is on, widget.js only after the click. */
function renderEmbed(host: HTMLElement, o: EmbedOptions, idPrefix: string): void {
const doc = host.ownerDocument;
const el = createContainer(o, idPrefix, doc);
const start = () => {
host.replaceChildren(el);
loadWidget(el, doc);
};
if (o.consent === false) return start();
const height = !o.height || o.height === 'auto' ? (o.height === 'auto' ? '760px' : '640px') : o.height;
const [text, label] = CONSENT_TEXT[consentLang(o.lang, doc)];
const box = doc.createElement('div');
box.setAttribute('data-msl-consent-placeholder', '');
box.setAttribute('style', `height:${height};display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;background:#f6f7f9;border-radius:12px;font:14px system-ui;text-align:center;padding:24px;box-sizing:border-box`);
const p = doc.createElement('p');
p.setAttribute('style', 'max-width:420px;margin:0;color:#475467');
p.textContent = text;
const b = doc.createElement('button');
b.type = 'button';
b.setAttribute('style', 'background:#5b55d9;color:#fff;border:0;border-radius:10px;padding:10px 18px;font-weight:600;cursor:pointer');
b.textContent = label;
b.addEventListener('click', start);
box.append(p, b);
host.replaceChildren(box);
}
interface Connected { token: string; map: string }
/**
* Opens the connect flow in a popup (return_mode=postmessage). Resolves with the chosen map when the app posts
* { type: 'msl:connected', token, map, state } from https://app.my-store-locator.com with our state; null if the popup is closed.
*/
function connectPopup(platform: string, win: Window = window): Promise<Connected | null> {
const state = Array.from(win.crypto.getRandomValues(new Uint8Array(16)), (x) => x.toString(16).padStart(2, '0')).join('');
const origin = win.location.origin;
const url = `${APP}/connect?platform=${encodeURIComponent(platform)}&return_mode=postmessage&origin=${encodeURIComponent(origin)}&state=${state}`;
const popup = win.open(url, 'msl-connect', 'popup,width=520,height=760');
return new Promise((resolve) => {
let timer: ReturnType<typeof setInterval> | undefined;
let finished = false;
const done = (v: Connected | null) => {
if (finished) return;
finished = true;
win.removeEventListener('message', onMessage);
if (timer) clearInterval(timer);
resolve(v);
};
function onMessage(e: MessageEvent) {
if (e.origin !== APP) return;
const d = e.data as { type?: string; token?: string; map?: string; state?: string } | null;
if (!d || d.type !== 'msl:connected' || d.state !== state) return;
const token = extractToken(d.token);
if (token) done({ token, map: String(d.map || '') });
}
win.addEventListener('message', onMessage);
if (!popup) return done(null);
// the app closes the popup right after posting – give the message a moment before treating "closed" as cancelled
timer = setInterval(() => {
if (!popup.closed) return;
if (timer) clearInterval(timer);
timer = undefined;
setTimeout(() => done(null), 1500);
}, 800);
});
}
// ---- component ----
const UTM = "utm_source=framer&utm_medium=integration&utm_campaign=framer-plugin"
type HeightMode = "fill" | "auto" | "fixed"
interface Props {
token?: string
heightMode?: HeightMode
fixedHeight?: number
language?: "auto" | "de" | "en"
consent?: boolean
lazy?: boolean
layout?: "standard" | "split"
offset?: number
style?: CSSProperties
}
/**
* @framerSupportedLayoutWidth any-prefer-fixed
* @framerSupportedLayoutHeight any
* @framerIntrinsicWidth 900
* @framerIntrinsicHeight 640
* @framerDisableUnlink
*/
export default function MyStoreLocator(props: Props) {
const {
token: rawToken = "",
heightMode = "fixed",
fixedHeight = 640,
language = "auto",
consent = true,
lazy = true,
layout = "standard",
offset = 0,
style,
} = props
const host = useRef<HTMLDivElement>(null)
const token = extractToken(rawToken)
const onCanvas = RenderTarget.current() === RenderTarget.canvas
const [connected, setConnected] = useState<Connected | null>(null)
useEffect(() => {
const box = host.current
if (!box || !token) return
const height = heightMode === "auto" ? "auto" : heightMode === "fill" ? "100%" : `${Math.max(200, fixedHeight || 640)}px`
renderEmbed(
box,
{
token,
height,
lang: language === "auto" ? "" : language,
consent: consent && !onCanvas, // the canvas shows the map directly
lazy,
layout: heightMode === "auto" ? layout : "",
offset: heightMode === "auto" ? offset : 0,
},
"msl-framer"
)
return () => box.replaceChildren()
}, [token, heightMode, fixedHeight, language, consent, lazy, layout, offset, onCanvas])
const outer: CSSProperties = {
width: "100%",
height: heightMode === "fill" ? "100%" : undefined,
minHeight: heightMode === "fill" ? 200 : undefined,
...style,
}
if (!token) {
return (
<div style={{ ...outer, ...placeholder }}>
<strong style={{ fontSize: 16, color: "#101B30" }}>My Store Locator</strong>
{connected ? (
<span style={{ maxWidth: 440 }}>
Connected to <b>{connected.map || "your map"}</b>. Paste this token into the <b>Map token</b> property:{" "}
<code style={{ userSelect: "all", background: "#fff", padding: "2px 6px", borderRadius: 6 }}>{connected.token}</code>
</span>
) : (
<span style={{ maxWidth: 420 }}>
{rawToken
? "This does not look like a map token. Paste the token, the embed code or the map link from My Store Locator → Embed."
: "Paste your map token (or the embed code / map link) in the properties panel – or get it with Connect (in the preview)."}
</span>
)}
<button
type="button"
onClick={() => connectPopup("framer").then((r) => r && setConnected(r))}
style={{ background: "#5B2FF7", color: "#fff", border: 0, borderRadius: 10, padding: "8px 16px", fontWeight: 600, cursor: "pointer" }}
>
Connect My Store Locator
</button>
<a href={`${APP}/#/register?${UTM}`} target="_blank" rel="noopener" style={{ color: "#5B2FF7", fontWeight: 600 }}>
No map yet? Create one for free →
</a>
</div>
)
}
return <div ref={host} style={outer} />
}
const placeholder: CSSProperties = {
boxSizing: "border-box",
minHeight: 240,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 10,
padding: 24,
textAlign: "center",
font: "14px/1.5 system-ui, sans-serif",
color: "#475467",
background: "linear-gradient(135deg, rgba(91,47,247,0.08), rgba(247,44,179,0.08))",
borderRadius: 16,
}
addPropertyControls(MyStoreLocator, {
token: {
type: ControlType.String,
title: "Map token",
defaultValue: "",
placeholder: "Token, embed code or map link",
description: "From My Store Locator → Embed. [Create a free map](https://app.my-store-locator.com/#/register?utm_source=framer)",
},
heightMode: {
type: ControlType.Enum,
title: "Height",
defaultValue: "fixed",
options: ["fixed", "fill", "auto"],
optionTitles: ["Fixed", "Fill frame", "Auto (grow with list)"],
displaySegmentedControl: true,
},
fixedHeight: {
type: ControlType.Number,
title: "Pixels",
defaultValue: 640,
min: 200,
max: 2000,
step: 10,
unit: "px",
hidden: (p: Props) => p.heightMode !== "fixed",
},
layout: {
type: ControlType.Enum,
title: "Layout",
defaultValue: "standard",
options: ["standard", "split"],
optionTitles: ["Standard", "Split (list + sticky map)"],
hidden: (p: Props) => p.heightMode !== "auto",
},
offset: {
type: ControlType.Number,
title: "Header offset",
defaultValue: 0,
min: 0,
max: 300,
unit: "px",
description: "Height of a sticky site header.",
hidden: (p: Props) => p.heightMode !== "auto",
},
language: {
type: ControlType.Enum,
title: "Language",
defaultValue: "auto",
options: ["auto", "de", "en"],
optionTitles: ["Map default", "Deutsch", "English"],
},
consent: {
type: ControlType.Boolean,
title: "Click to load",
defaultValue: true,
enabledTitle: "On",
disabledTitle: "Off",
description: "GDPR: nothing is loaded from My Store Locator before the visitor clicks (recommended in the EU).",
},
lazy: { type: ControlType.Boolean, title: "Lazy load", defaultValue: true, enabledTitle: "On", disabledTitle: "Off" },
})
The component is this one file and only needs framer and react, which Framer provides. It only loads the widget from https://app.my-store-locator.com.
Settings in the properties panel
| Setting | What it does |
|---|---|
| Map token | Your map: token, map address or embed code |
| Height | Fixed (fixed height in pixels), Fill frame (fills the frame) or Auto (grows with the list, set the frame height to Fit) |
| Layout | With Auto: Standard or Split (list with a map that stays in view) |
| Header offset | With Auto: height of a sticky site header in pixels |
| Language | Map language: map default, German or English |
| Click to load | Two-click consent, On by default |
| Lazy load | Load the map only when it scrolls into view |
Without a token, the component shows a note with Connect My Store Locator. In the preview, the button opens the sign-in and lets you pick your map. Then copy the token shown into Map token - Framer does not let components change their own settings.
Without the code component: Embed component
- In Embed Studio, copy your map's address from the Link & QR tab (it starts with
https://app.my-store-locator.com/#/m/). - In Framer, open the Insert menu, search for Embed and drag the component onto the page.
- In the Embed section on the right, choose the type URL and paste the address.
- Set width (for example Fill) and height (for example 640 px).
- Publish the page and check the map on the live site.
Alternatively, choose the type HTML and paste the iframe code from Embed Studio:
<iframe src="https://app.my-store-locator.com/#/m/YOUR-TOKEN" title="Store locator" style="width:100%;height:640px;border:0;max-width:100%" loading="lazy" allow="geolocation"></iframe>For phones, set a different height using Framer's breakpoints, for example 560 px.
Privacy: two-click consent
In the code component, Click to load is on by default. On the published site, visitors first see a placeholder with a privacy notice. Nothing is loaded from My Store Locator until they click Load map. In the Framer editor the component shows the map directly, so you can see the layout. More under GDPR & security.
With the Embed component, turn on "Load the map only after a click (GDPR)" in Embed Studio and copy the code again. With the URL type, Framer only takes the address. For the placeholder with a privacy notice, use the HTML type with the code that Embed Studio generates when consent mode is on.
Updates
A copied code component does not update itself. For a new version, replace the content of the MyStoreLocator file with the code from this page. Your settings are kept. As soon as the component is in the Framer Marketplace, you will find it here.
If the map does not show up
- Note "This does not look like a map token": copy the token, address or embed code again in full from Embed Studio.
- Blank in the editor (Embed component): check the published page or the preview.
- "Map not available": the map is still a draft. Publish it in the app.
All options are explained in the embedding guide.
FAQ
Code component or Embed component - which is better?
The code component: you set height, language and two-click consent in the properties panel, and with Auto the map grows with the list. The Embed component is quicker to set up if you only need a map with a fixed height.
Is the component in the Framer Marketplace yet?
It has been submitted and is in review. Until it is approved, copy the code from this page. Your pages keep working afterwards.
Can I show the map in several languages?
Yes. In the code component, choose German or English under Language. With the Embed component, use an address with ?lang=en on the English page, which Embed Studio creates for you.
Do I need a particular Framer plan?
We found no restriction on code components or the Embed component. Check Framer's current plans if you want to use your own domain.
Try it with your own locations.
Create an account, import your locations, embed the map. The Free plan stays free - and you can test Pro for 14 days.