Skip to content
UI Kit
Accent color

Store locator

Finding a store. A drawn map with a list that lights up together, the page of one store with the week and its services, store cards with region chips and a finder for the nearest store.

One of 4 components in Delivery and stores

4 variants

Variants

Map and list

A drawn district map with pins that drop in one after another, next to a list with search. Hovering or picking a store lights up its pin with a halo and a label, a tap on a pin scrolls the list to it. On phones list and map are two views behind a switch.

Open in Studio

Store page

One store, open now with a breathing dot, a photo that drifts while scrolling, services that rise one after another, the pin on the map, directions and call, and the week with today marked.

Open in Studio

Store cards

Store cards with photo, district, distance and open state. District chips and an "open now" switch filter the cards, which run in again on every change. Cards lift on hover, the photo zooms inside.

Open in Studio

Nearest store

Use my location or a postcode. While it looks, a ring pulses over the map, then the nearest store runs in with distance, travel time and open state, plus two more nearby. "Make this my store" confirms with a check.

Open in Studio

Details

What Store locator brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/store-locator.tsx
Brings along
utilskitbuttoniconrevealmotionsectionmapsproduct-image
npm packages
None
Languages
English and German, 30 texts of its own
In collections
Local business

Props

The StoreLocatorProps interface from components/ui/store-locator.tsx, with the comments from the code.

  • variantoptional
    StoreLocatorVariant

    map-liststore-detailcardsnearest

  • eyebrowoptional
    string
  • titleoptional
    string
  • descriptionoptional
    string
  • storesoptional
    Store[]
  • storeIdoptional
    string

    store-detail: the store to show, default the first

  • mapoptional
    React.ReactNode

    The map under the pins, default a drawn district map

  • defaultLocatedoptional
    boolean

    nearest: show the result right away (previews)

  • onLocateoptional
    () => void

Built-in texts

Store locator writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 30 texts
searchEnglish City or postcodeGerman Stadt oder Postleitzahl
searchLabelEnglish Search storesGerman Läden suchen
storesEnglish storesGerman Läden
storeEnglish storeGerman Laden
listEnglish ListGerman Liste
mapEnglish MapGerman Karte
closedEnglish ClosedGerman Geschlossen
openEnglish OpenGerman Geöffnet
directionsEnglish Get directionsGerman Route planen
callEnglish CallGerman Anrufen
detailsEnglish Store detailsGerman Zum Laden
hoursEnglish Opening hoursGerman Öffnungszeiten
servicesEnglish In this storeGerman In diesem Laden
noMatchEnglish No store matches that yet. Try a city or show all stores.German Dazu passt noch kein Laden. Versuch eine Stadt oder zeig alle Läden.
showAllEnglish Show all storesGerman Alle Läden zeigen
allEnglish AllGerman Alle
onlyOpenEnglish Open now onlyGerman Nur geöffnete
todayEnglish TodayGerman Heute
locateEnglish Use my locationGerman Meinen Standort nutzen
locatingEnglish Finding youGerman Standort wird gesucht
orPostcodeEnglish or enter a postcodeGerman oder Postleitzahl eingeben
postcodeEnglish PostcodeGerman Postleitzahl
findEnglish FindGerman Suchen
nearestEnglish Nearest to youGerman Am nächsten bei dir
alsoNearEnglish Also near youGerman Auch in deiner Nähe
chooseEnglish Choose this storeGerman Diesen Laden wählen
chosenEnglish Your storeGerman Dein Laden
phoneEnglish PhoneGerman Telefon
addressEnglish AddressGerman Adresse
pinLabelEnglish Show on mapGerman Auf der Karte zeigen

Accessibility

Read from the source of the component.

  • Sets aria-checked, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles group, radio, radiogroup and switch.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 6 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 648 lines
components/ui/store-locator.tsx
'use client';

import { useId, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollProgress } from '@/components/ui/motion';
import { Eyebrow, Section } from '@/components/ui/section';
import { DistrictMap } from '@/components/ui/maps';
import { ShopMotion, Swap, formatNumber } from '@/components/ui/product-image';

/*
 * Finding a store: a map with a list, the page of one store, store cards
 * with filters and a small "nearest store" finder.
 *
 *   map-list    a drawn map with pins and a list; picking one lights up the other
 *   store-detail  one store: open now, the week, services, how to get there
 *   cards       store cards with region chips and an "open now" switch
 *   nearest     use my location or a postcode, the nearest store and two more
 *
 * The map is drawn (maps.tsx). Pin positions are percent of the map, x from
 * the left, y from the top. Pass your own map node to use a real one.
 */

export type StoreLocatorVariant = 'map-list' | 'store-detail' | 'cards' | 'nearest';

export interface StoreHours {
  /** e.g. "Monday" */
  day: string;
  /** e.g. "10:00 to 20:00" or "Closed" */
  hours: string;
  today?: boolean;
}

export interface Store {
  id: string;
  name: string;
  address: string;
  /** City or district, used by the region chips */
  region?: string;
  /** Pin position on the map, percent */
  x?: number;
  y?: number;
  /** Already formatted, e.g. "1.2 km" */
  distance?: string;
  /** e.g. "8 min on foot" */
  travel?: string;
  open?: boolean;
  /** e.g. "Closes at 8 pm" or "Opens at 10 am" */
  status?: string;
  phone?: string;
  hours?: StoreHours[];
  services?: { icon?: IconName; label: string }[];
  image?: React.ReactNode;
  href?: string;
  /** store-detail: a line about the store */
  note?: string;
}

export interface StoreLocatorProps {
  variant?: StoreLocatorVariant;
  eyebrow?: string;
  title?: string;
  description?: string;
  stores?: Store[];
  /** store-detail: the store to show, default the first */
  storeId?: string;
  /** The map under the pins, default a drawn district map */
  map?: React.ReactNode;
  /** nearest: show the result right away (previews) */
  defaultLocated?: boolean;
  onLocate?: () => void;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    search: 'City or postcode',
    searchLabel: 'Search stores',
    stores: 'stores',
    store: 'store',
    list: 'List',
    map: 'Map',
    closed: 'Closed',
    open: 'Open',
    directions: 'Get directions',
    call: 'Call',
    details: 'Store details',
    hours: 'Opening hours',
    services: 'In this store',
    noMatch: 'No store matches that yet. Try a city or show all stores.',
    showAll: 'Show all stores',
    all: 'All',
    onlyOpen: 'Open now only',
    today: 'Today',
    locate: 'Use my location',
    locating: 'Finding you',
    orPostcode: 'or enter a postcode',
    postcode: 'Postcode',
    find: 'Find',
    nearest: 'Nearest to you',
    alsoNear: 'Also near you',
    choose: 'Choose this store',
    chosen: 'Your store',
    phone: 'Phone',
    address: 'Address',
    pinLabel: 'Show on map',
  },
  de: {
    search: 'Stadt oder Postleitzahl',
    searchLabel: 'Läden suchen',
    stores: 'Läden',
    store: 'Laden',
    list: 'Liste',
    map: 'Karte',
    closed: 'Geschlossen',
    open: 'Geöffnet',
    directions: 'Route planen',
    call: 'Anrufen',
    details: 'Zum Laden',
    hours: 'Öffnungszeiten',
    services: 'In diesem Laden',
    noMatch: 'Dazu passt noch kein Laden. Versuch eine Stadt oder zeig alle Läden.',
    showAll: 'Alle Läden zeigen',
    all: 'Alle',
    onlyOpen: 'Nur geöffnete',
    today: 'Heute',
    locate: 'Meinen Standort nutzen',
    locating: 'Standort wird gesucht',
    orPostcode: 'oder Postleitzahl eingeben',
    postcode: 'Postleitzahl',
    find: 'Suchen',
    nearest: 'Am nächsten bei dir',
    alsoNear: 'Auch in deiner Nähe',
    choose: 'Diesen Laden wählen',
    chosen: 'Dein Laden',
    phone: 'Telefon',
    address: 'Adresse',
    pinLabel: 'Auf der Karte zeigen',
  },
};

type Texts = (typeof TEXTS)['en'];

const FOCUS = 'outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]';
const CARD = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';

function Head({ eyebrow, title, description }: { eyebrow?: string; title?: string; description?: string }) {
  if (!title) return null;
  return (
    <div className="mb-10 max-w-[40rem] sm:mb-12">
      {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
      <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{title}</h2>
      {description && (
        <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
          {description}
        </p>
      )}
    </div>
  );
}

/** Open or closed with a breathing dot while open */
function OpenState({ store, t, className }: { store: Store; t: Texts; className?: string }) {
  if (store.open === undefined && !store.status) return null;
  return (
    <span className={cn('inline-flex items-center gap-1.5 text-[13px]', className)}>
      <span className="relative flex size-2" aria-hidden>
        {store.open && <span className="absolute inset-0 animate-kit-pulse rounded-full bg-success" />}
        <span className={cn('relative size-2 rounded-full', store.open ? 'bg-success' : 'bg-subtle-foreground')} />
      </span>
      <span className={cn('font-medium', store.open ? 'text-success-foreground' : 'text-muted-foreground')}>{store.open ? t.open : t.closed}</span>
      {store.status && <span className="text-muted-foreground">· {store.status}</span>}
    </span>
  );
}

/** A pin on the map. The active one grows, gets a label and a soft halo. */
function Pin({ store, active, onPick, index, label }: { store: Store; active: boolean; onPick: () => void; index: number; label: string }) {
  return (
    <button
      type="button"
      onClick={onPick}
      aria-label={`${label}, ${store.name}`}
      aria-pressed={active}
      className={cn('group/pin absolute z-10 after:absolute after:-inset-2 after:content-[""] -translate-x-1/2 -translate-y-full rounded-full', active && 'z-20', FOCUS)}
      style={{ left: `${store.x ?? 50}%`, top: `${store.y ?? 50}%` }}
    >
      <span className={cn('absolute left-1/2 top-full size-12 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[color-mix(in_oklab,var(--primary)_22%,transparent)] transition-[opacity,transform] duration-500', active ? 'scale-100 animate-kit-pulse opacity-100' : 'scale-50 opacity-0')} aria-hidden />
      <span className={cn('relative block origin-bottom transition-transform duration-300 ease-[var(--ease-kit)] group-hover/pin:-translate-y-1 animate-[kitshop-pop_0.5s_var(--ease-kit)_both]', active ? 'scale-125' : 'scale-100')} style={{ animationDelay: `${200 + index * 90}ms` }}>
        <svg viewBox="0 0 24 30" className="h-9 w-[29px] drop-shadow-[0_4px_6px_rgb(0_0_0/0.25)]" aria-hidden>
          <path d="M12 29c5.6-6 10-11 10-16.5A10 10 0 0 0 2 12.5C2 18 6.4 23 12 29Z" className={cn('transition-colors duration-300', active ? 'fill-primary' : 'fill-foreground')} />
          <circle cx="12" cy="12" r="4" className={cn(active ? 'fill-primary-foreground' : 'fill-background')} />
        </svg>
      </span>
      {active && (
        <span className="absolute bottom-full left-1/2 mb-2 -translate-x-1/2 animate-[kitshop-in_0.35s_var(--ease-kit)] whitespace-nowrap rounded-lg border border-border bg-popover px-2.5 py-1.5 text-[12.5px] font-medium text-foreground shadow-[var(--shadow-md)]">
          {store.name}
        </span>
      )}
    </button>
  );
}

function MapPanel({ stores, active, onPick, map, t, className }: { stores: Store[]; active: string; onPick: (id: string) => void; map?: React.ReactNode; t: Texts; className?: string }) {
  const ref = useScrollProgress<HTMLDivElement>();
  return (
    <div ref={ref} className={cn('relative isolate aspect-[10/7] overflow-hidden rounded-2xl border border-border bg-surface-2', className)}>
      {/* The map drifts a little while scrolling, the pins stay on their spots */}
      <div data-clip-ok className="absolute inset-0 scale-[1.06] motion-reduce:!transform-none" style={{ transform: 'translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * 3%), 0) scale(1.06)' }}>
        {map ?? <DistrictMap className="size-full" />}
      </div>
      <div className="absolute inset-0">
        {stores.map((s, i) => (
          <Pin key={s.id} store={s} index={i} active={s.id === active} onPick={() => onPick(s.id)} label={t.pinLabel} />
        ))}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* map-list                                                            */
/* ------------------------------------------------------------------ */

function MapList(p: StoreLocatorProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const stores = p.stores ?? [];
  const [query, setQuery] = useState('');
  const [active, setActive] = useState(stores[0]?.id ?? '');
  const [view, setView] = useState<'list' | 'map'>('list');
  const listRef = useRef<HTMLUListElement>(null);
  const id = useId();
  const q = query.trim().toLowerCase();
  const shown = useMemo(() => (q ? stores.filter((s) => `${s.name} ${s.address} ${s.region ?? ''}`.toLowerCase().includes(q)) : stores), [stores, q]);
  const pick = (sid: string, fromMap = false) => {
    setActive(sid);
    if (fromMap) {
      const el = listRef.current?.querySelector<HTMLElement>(`[data-store="${sid}"]`);
      el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
    }
  };
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,24rem)_minmax(0,1fr)] lg:gap-6">
        <div className="flex min-h-0 flex-col gap-3 lg:relative">
          <div className="flex gap-2">
            <label htmlFor={id} className="relative block flex-1">
              <span className="sr-only">{t.searchLabel}</span>
              <Icon name="search" size={16} className="pointer-events-none absolute start-3.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
              <input
                id={id}
                type="search"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder={t.search}
                className="h-11 w-full rounded-lg border border-input bg-background ps-10 pe-3 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground hover:border-border-strong focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]"
              />
            </label>
            {/* Phones: list and map as two views of the same stores */}
            <div role="radiogroup" aria-label={`${t.list}, ${t.map}`} className="relative grid h-11 shrink-0 grid-cols-2 rounded-lg border border-border-strong bg-surface p-1 lg:hidden">
              <span aria-hidden className="absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]" style={{ transform: `translateX(${view === 'map' ? 100 : 0}%)` }} />
              {(['list', 'map'] as const).map((v) => (
                <button key={v} type="button" role="radio" aria-checked={view === v} onClick={() => setView(v)} className={cn('relative z-10 grid w-12 place-items-center after:absolute after:inset-x-0 after:-inset-y-[5px] after:content-[""] rounded-md transition-colors', view === v ? 'text-foreground' : 'text-muted-foreground', FOCUS)} aria-label={v === 'list' ? t.list : t.map}>
                  <Icon name={v === 'list' ? 'list' : 'map'} size={17} />
                </button>
              ))}
            </div>
          </div>
          <p className="text-[13px] text-muted-foreground" aria-live="polite">
            <span className="font-medium tabular-nums text-foreground">{formatNumber(locale, shown.length)}</span> {shown.length === 1 ? t.store : t.stores}
          </p>
          <div className={cn('min-h-0 lg:relative lg:flex-1', view === 'map' && 'max-lg:hidden')}>
            {shown.length ? (
              <ul ref={listRef} key={q} className="kit-scrollbar space-y-2 lg:absolute lg:inset-0 lg:overflow-y-auto lg:pb-10 lg:pe-1 lg:[mask-image:linear-gradient(to_bottom,black_86%,transparent)]">
                {shown.map((s, i) => {
                  const on = s.id === active;
                  return (
                    <li key={s.id} data-store={s.id} className="animate-[kitshop-in_0.4s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(i, 6) * 50}ms` }}>
                      <button
                        type="button"
                        aria-pressed={on}
                        onClick={() => pick(s.id)}
                        onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(s.id)}
                        className={cn('w-full rounded-xl border p-4 text-left transition-[border-color,background-color,box-shadow] duration-300', on ? 'border-foreground bg-card shadow-[0_0_0_1px_var(--foreground)]' : 'border-border bg-card hover:border-border-strong', FOCUS)}
                      >
                        <span className="flex items-baseline justify-between gap-3">
                          <span className="text-[15px] font-semibold text-foreground">{s.name}</span>
                          {s.distance && <span className="shrink-0 text-[12.5px] tabular-nums text-muted-foreground">{s.distance}</span>}
                        </span>
                        <span className="mt-0.5 block text-[13.5px] text-muted-foreground">{s.address}</span>
                        <OpenState store={s} t={t} className="mt-2" />
                      </button>
                    </li>
                  );
                })}
              </ul>
            ) : (
              <div className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border-strong p-8 text-center">
                <p className="text-pretty text-[14px] text-muted-foreground">{t.noMatch}</p>
                <Button variant="outline" size="sm" onClick={() => setQuery('')}>
                  {t.showAll}
                </Button>
              </div>
            )}
          </div>
        </div>
        <Reveal effect="scale" className={cn(view === 'list' && 'max-lg:hidden')}>
          <MapPanel stores={shown} active={active} onPick={(sid) => pick(sid, true)} map={p.map} t={t} />
          {/* Phones: the picked store under the map, so the map view stands on its own */}
          {(() => {
            const s = shown.find((x) => x.id === active);
            return s ? (
              <div key={s.id} className={cn(CARD, 'mt-3 animate-[kitshop-in_0.4s_var(--ease-kit)] p-4 lg:hidden')} aria-live="polite">
                <div className="flex items-baseline justify-between gap-3">
                  <p className="text-[15px] font-semibold text-foreground">{s.name}</p>
                  {s.distance && <span className="text-[12.5px] tabular-nums text-muted-foreground">{s.distance}</span>}
                </div>
                <p className="mt-0.5 text-[13.5px] text-muted-foreground">{s.address}</p>
                <div className="mt-3 flex items-center justify-between gap-3">
                  <OpenState store={s} t={t} />
                  <Button href={s.href ?? '#'} size="sm" variant="outline" icon="arrow-right" className="h-11">
                    {t.details}
                  </Button>
                </div>
              </div>
            ) : null;
          })()}
        </Reveal>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* store-detail                                                        */
/* ------------------------------------------------------------------ */

function StoreDetail(p: StoreLocatorProps & { t: Texts }) {
  const { t } = p;
  const stores = p.stores ?? [];
  const s = stores.find((x) => x.id === p.storeId) ?? stores[0];
  const parallax = useScrollProgress<HTMLDivElement>();
  if (!s) return null;
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className="grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-14">
        <div>
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground">{p.title ?? s.name}</h2>
          <OpenState store={s} t={t} className="kit-rise mt-4 text-[14px]" />
          {s.note && (
            <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '80ms' } as React.CSSProperties}>
              {s.note}
            </p>
          )}
          {s.image && (
            <div ref={parallax} className="relative mt-8 aspect-[16/10] overflow-hidden rounded-2xl bg-surface-2">
              <div className="absolute inset-x-0 -inset-y-[6%] motion-reduce:!transform-none [&>*]:size-full" style={{ transform: 'translate3d(0, calc((0.5 - var(--kit-progress, 0.5)) * 8%), 0)' }}>
                {s.image}
              </div>
            </div>
          )}
          {s.services?.length ? (
            <div className="mt-8">
              <h3 className="text-[15px] font-semibold text-foreground">{t.services}</h3>
              <Stagger step={60} as="ul" className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
                {s.services.map((sv) => (
                  <li key={sv.label} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 text-[14px] text-foreground">
                    <span className="grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
                      <Icon name={sv.icon ?? 'check'} size={17} />
                    </span>
                    {sv.label}
                  </li>
                ))}
              </Stagger>
            </div>
          ) : null}
        </div>

        <div className="lg:pt-2">
          <Reveal effect="rise" className={cn(CARD, 'overflow-hidden')}>
            <MapPanel stores={[{ ...s, x: 50, y: 62 }]} active={s.id} onPick={() => {}} map={p.map} t={t} className="rounded-none border-0 border-b" />
            <div className="space-y-5 p-6">
              <div>
                <p className="text-[12.5px] text-muted-foreground">{t.address}</p>
                <p className="mt-0.5 text-[15px] font-medium text-foreground">{s.address}</p>
              </div>
              {s.phone && (
                <div>
                  <p className="text-[12.5px] text-muted-foreground">{t.phone}</p>
                  <a href={`tel:${s.phone.replace(/\s/g, '')}`} className={cn('relative mt-0.5 inline-block rounded-sm after:absolute after:inset-x-0 after:-inset-y-[11px] after:content-[""] text-[15px] font-medium tabular-nums text-foreground hover:underline', FOCUS)}>
                    {s.phone}
                  </a>
                </div>
              )}
              <div className="grid grid-cols-2 gap-2">
                <Button href={s.href ?? '#'} iconLeft="navigation">
                  {t.directions}
                </Button>
                {s.phone && (
                  <Button href={`tel:${s.phone.replace(/\s/g, '')}`} variant="outline" iconLeft="phone">
                    {t.call}
                  </Button>
                )}
              </div>
            </div>
            {s.hours?.length ? (
              <div className="border-t border-border p-6">
                <h3 className="text-[15px] font-semibold text-foreground">{t.hours}</h3>
                <dl className="mt-3 divide-y divide-border">
                  {s.hours.map((h) => (
                    <div key={h.day} className={cn('-mx-3 flex justify-between gap-4 rounded-lg px-3 py-2.5 text-[14px]', h.today && 'bg-[color-mix(in_oklab,var(--primary)_10%,transparent)]')}>
                      <dt className={cn(h.today ? 'font-semibold text-foreground' : 'text-muted-foreground')}>
                        {h.day}
                        {h.today && <span className="ms-2 text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{t.today}</span>}
                      </dt>
                      <dd className={cn('tabular-nums', h.today ? 'font-semibold text-foreground' : 'text-foreground')}>{h.hours}</dd>
                    </div>
                  ))}
                </dl>
              </div>
            ) : null}
          </Reveal>
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* cards                                                               */
/* ------------------------------------------------------------------ */

function Cards(p: StoreLocatorProps & { t: Texts }) {
  const { t } = p;
  const locale = useLocale();
  const stores = p.stores ?? [];
  const regions = Array.from(new Set(stores.map((s) => s.region).filter(Boolean))) as string[];
  const [region, setRegion] = useState<string | null>(null);
  const [onlyOpen, setOnlyOpen] = useState(false);
  const shown = stores.filter((s) => (region ? s.region === region : true)).filter((s) => (onlyOpen ? s.open : true));
  const key = `${region}-${onlyOpen}`;
  return (
    <Section className={p.className}>
      <ShopMotion />
      <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
      <div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
        <div className="kit-no-scrollbar kit-fade-end -mx-6 flex gap-2 overflow-x-auto px-6 py-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:[mask-image:none]" role="group" aria-label={t.searchLabel}>
          {[null, ...regions].map((r) => (
            <button
              key={r ?? 'all'}
              type="button"
              aria-pressed={region === r}
              onClick={() => setRegion(r)}
              className={cn('inline-flex h-11 shrink-0 items-center rounded-full border px-4 text-[13.5px] font-medium transition-colors sm:h-10', region === r ? 'border-foreground bg-foreground text-background' : 'border-border-strong text-muted-foreground hover:bg-accent hover:text-foreground', FOCUS)}
            >
              {r ?? t.all}
            </button>
          ))}
        </div>
        <button type="button" role="switch" aria-checked={onlyOpen} onClick={() => setOnlyOpen(!onlyOpen)} className={cn('flex min-h-11 shrink-0 items-center gap-3 rounded-lg text-[14px] font-medium text-foreground', FOCUS)}>
          <span aria-hidden className={cn('relative h-6 w-11 rounded-full transition-colors duration-300', onlyOpen ? 'bg-success' : 'bg-[color-mix(in_oklab,var(--foreground)_18%,transparent)]')}>
            <span className={cn('absolute left-0.5 top-0.5 size-5 rounded-full bg-background shadow-[var(--shadow-sm)] transition-transform duration-300 ease-[var(--ease-kit)]', onlyOpen && 'translate-x-5')} />
          </span>
          {t.onlyOpen}
        </button>
      </div>
      <p className="sr-only" aria-live="polite">
        {formatNumber(locale, shown.length)} {t.stores}
      </p>
      {shown.length ? (
        <ul key={key} className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
          {shown.map((s, i) => (
            <li key={s.id} className="animate-[kitshop-in_0.5s_var(--ease-kit)_both]" style={{ animationDelay: `${Math.min(i, 6) * 70}ms` }}>
              <a href={s.href ?? '#'} className={cn('group/card flex h-full flex-col rounded-2xl border border-border bg-card p-2.5 shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,box-shadow] duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:shadow-[var(--highlight),var(--shadow-lg)] motion-reduce:transition-none motion-reduce:hover:translate-y-0', FOCUS)}>
                <span className="relative block aspect-[16/10] overflow-hidden rounded-xl bg-surface-2">
                  <span className="block size-full transition-transform duration-700 ease-[var(--ease-kit)] group-hover/card:scale-[1.05] motion-reduce:transition-none [&>*]:size-full">{s.image}</span>
                  {s.region && <span className="absolute left-3 top-3 rounded-full bg-background/90 px-2.5 py-1 text-[12px] font-medium text-foreground backdrop-blur">{s.region}</span>}
                </span>
                <span className="flex flex-1 flex-col px-2.5 pb-2 pt-4">
                  <span className="flex items-baseline justify-between gap-3">
                    <span className="text-[17px] font-semibold tracking-[-0.01em] text-foreground">{s.name}</span>
                    {s.distance && <span className="shrink-0 text-[12.5px] tabular-nums text-muted-foreground">{s.distance}</span>}
                  </span>
                  <span className="mt-0.5 text-[14px] text-muted-foreground">{s.address}</span>
                  <OpenState store={s} t={t} className="mt-3" />
                  <span className="mt-auto inline-flex items-center gap-1.5 pt-4 text-[14px] font-medium text-foreground">
                    {t.details}
                    <Icon name="arrow-right" size={14} className="transition-transform duration-300 group-hover/card:translate-x-1" />
                  </span>
                </span>
              </a>
            </li>
          ))}
        </ul>
      ) : (
        <div className="flex flex-col items-center gap-4 rounded-2xl border border-dashed border-border-strong p-10 text-center">
          <p className="max-w-[24rem] text-pretty text-[14px] text-muted-foreground">{t.noMatch}</p>
          <Button variant="outline" onClick={() => { setRegion(null); setOnlyOpen(false); }}>
            {t.showAll}
          </Button>
        </div>
      )}
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* nearest                                                             */
/* ------------------------------------------------------------------ */

function Nearest(p: StoreLocatorProps & { t: Texts }) {
  const { t } = p;
  const stores = p.stores ?? [];
  const [state, setState] = useState<'idle' | 'locating' | 'done'>(p.defaultLocated ? 'done' : 'idle');
  const [code, setCode] = useState('');
  const [mine, setMine] = useState<string | null>(null);
  const id = useId();
  const find = () => {
    setState('locating');
    p.onLocate?.();
    window.setTimeout(() => setState('done'), 900);
  };
  const [first, ...rest] = stores;
  return (
    <Section className={p.className}>
      <ShopMotion />
      <div className={cn(CARD, 'mx-auto grid max-w-[62rem] grid-cols-1 overflow-hidden md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]')}>
        <div className="flex flex-col p-6 sm:p-8">
          {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
          <h2 className="text-balance text-h3 font-semibold text-foreground">{p.title}</h2>
          {p.description && <p className="mt-3 text-pretty text-[15px] leading-relaxed text-muted-foreground">{p.description}</p>}
          <Button className="mt-6 w-full" size="lg" iconLeft={state === 'locating' ? 'loader' : 'locate'} disabled={state === 'locating'} onClick={find}>
            <Swap value={state === 'locating'}>{state === 'locating' ? t.locating : t.locate}</Swap>
          </Button>
          <p className="my-4 flex items-center gap-3 text-[12.5px] text-subtle-foreground">
            <span className="h-px flex-1 bg-border" />
            {t.orPostcode}
            <span className="h-px flex-1 bg-border" />
          </p>
          <form
            className="flex gap-2"
            onSubmit={(e) => {
              e.preventDefault();
              if (code.trim()) find();
            }}
          >
            <label htmlFor={id} className="sr-only">
              {t.postcode}
            </label>
            <input id={id} value={code} onChange={(e) => setCode(e.target.value)} inputMode="numeric" autoComplete="postal-code" placeholder={t.postcode} className="h-11 w-0 min-w-0 flex-1 rounded-lg border border-input bg-background px-3.5 text-[15px] text-foreground shadow-[var(--shadow-sm)] outline-none placeholder:text-subtle-foreground focus:border-border-strong focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_22%,transparent)]" />
            <Button type="submit" variant="outline">
              {t.find}
            </Button>
          </form>
        </div>

        <div className="relative min-h-[22rem] border-t border-border bg-surface md:border-l md:border-t-0">
          {state !== 'done' ? (
            <div className="absolute inset-0">
              <DistrictMap className={cn('size-full transition-[filter,opacity] duration-700', state === 'locating' ? 'opacity-80' : 'opacity-60 blur-[1px]')} />
              <span className="absolute left-1/2 top-1/2 grid size-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-background/90 text-foreground shadow-[var(--shadow-md)] backdrop-blur">
                <Icon name="locate" size={24} className={cn(state === 'locating' && 'animate-kit-pulse')} />
                {state === 'locating' && <span aria-hidden className="absolute inset-0 animate-ping rounded-full border-2 border-primary motion-reduce:animate-none" />}
              </span>
            </div>
          ) : (
            first && (
              <div className="flex h-full flex-col p-6 sm:p-8" aria-live="polite">
                <p className="text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]">{t.nearest}</p>
                <div className="mt-2 animate-[kitshop-in_0.5s_var(--ease-kit)_both]">
                  <div className="flex items-baseline justify-between gap-3">
                    <h3 className="text-[20px] font-semibold tracking-[-0.015em] text-foreground">{first.name}</h3>
                    {first.distance && <span className="text-[14px] font-medium tabular-nums text-foreground">{first.distance}</span>}
                  </div>
                  <p className="mt-0.5 text-[14px] text-muted-foreground">{first.address}</p>
                  <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
                    <OpenState store={first} t={t} />
                    {first.travel && (
                      <span className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground">
                        <Icon name="navigation" size={13} />
                        {first.travel}
                      </span>
                    )}
                  </div>
                  <div className="mt-5 grid grid-cols-1 gap-2 sm:grid-cols-2">
                    <Button size="sm" className="h-11 sm:h-10" variant={mine === first.id ? 'secondary' : 'primary'} iconLeft={mine === first.id ? 'check' : 'store'} onClick={() => setMine(first.id)}>
                      <Swap value={mine === first.id}>{mine === first.id ? t.chosen : t.choose}</Swap>
                    </Button>
                    <Button size="sm" className="h-11 sm:h-10" variant="outline" href={first.href ?? '#'} iconLeft="navigation">
                      {t.directions}
                    </Button>
                  </div>
                </div>
                {rest.length > 0 && (
                  <div className="mt-6 border-t border-border pt-5">
                    <p className="text-[12.5px] text-muted-foreground">{t.alsoNear}</p>
                    <ul className="mt-2 divide-y divide-border">
                      {rest.slice(0, 2).map((s, i) => (
                        <li key={s.id} className="animate-[kitshop-in_0.5s_var(--ease-kit)_both]" style={{ animationDelay: `${150 + i * 80}ms` }}>
                          <button type="button" onClick={() => setMine(s.id)} className={cn('flex w-full items-center justify-between gap-3 py-3 text-left', FOCUS)}>
                            <span className="min-w-0">
                              <span className="block truncate text-[14.5px] font-medium text-foreground">{s.name}</span>
                              <OpenState store={s} t={t} className="mt-0.5" />
                            </span>
                            <span className="flex shrink-0 items-center gap-2 text-[13px] tabular-nums text-muted-foreground">
                              {s.distance}
                              <Icon name={mine === s.id ? 'check-circle' : 'chevron-right'} size={16} className={cn(mine === s.id && 'text-success-foreground')} />
                            </span>
                          </button>
                        </li>
                      ))}
                    </ul>
                  </div>
                )}
              </div>
            )
          )}
        </div>
      </div>
    </Section>
  );
}

/* ------------------------------------------------------------------ */
/* The block                                                           */
/* ------------------------------------------------------------------ */

/**
 * Store finders in four layouts. map-list: map and list in sync.
 * store-detail: one store with hours and services. cards: filterable cards.
 * nearest: the nearest store from a location or postcode.
 */
export function StoreLocator(props: StoreLocatorProps) {
  const { variant = 'map-list' } = props;
  const t = useTexts(TEXTS, props.labels);
  if (variant === 'store-detail') return <StoreDetail {...props} t={t} />;
  if (variant === 'cards') return <Cards {...props} t={t} />;
  if (variant === 'nearest') return <Nearest {...props} t={t} />;
  return <MapList {...props} t={t} />;
}

Frequent questions

How do I install Store locator?

With aiw add @ai-website/store-locator. The command puts components/ui/store-locator.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, motion, section, maps and product-image. It needs no further npm packages.

Which variants does Store locator have?

4 variants, Map and list, Store page, Store cards and Nearest store. You pick one with the variant prop, for example variant="store-detail".

Can I translate the texts of Store locator?

Yes. Store locator carries 30 texts of its own in English and German, such as “City or postcode”, “Search stores” and “stores”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Store locator?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Store locator with aiw_components and install it with aiw_add @ai-website/store-locator. Name the variant in your prompt, for example variant="map-list", and the assistant writes your content straight into the props.

Can I adjust Store locator without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching StoreLocator code to copy.

Often searched

Jump to

Copied to the clipboard