Skip to main content

Map Loading Toast

A small status pill anchored at the top of the map view, surfacing the state of the bounds-based places query. It has three phases: hidden, loading, and success. The component lives at src/components/map/map-loading-toast.tsx and is mounted inside the <Map> element in src/components/map/google-map.tsx.

Why a custom toast and not react-hot-toast?

The app's global <Toaster position="bottom-center" /> (in src/app/layout.tsx) handles user-actionable notifications (errors, success messages from forms). The map's "places loaded" feedback is different:

  • It needs to anchor inside the map area, not the document body.
  • Its lifecycle is driven by a query state, not a one-shot toast.success(...) call.
  • It morphs from loadingsuccess in place, which react-hot-toast can't do without custom rendering.

So the toast is a self-contained inline element. The global <Toaster> is unaffected and continues to handle other notifications.

State Machine

type ToastPhase = 'hidden' | 'loading' | 'success';

const [phase, setPhase] = useState<ToastPhase>('hidden');
const wasLoadingRef = useRef(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

Transitions are driven by a single useEffect watching isLoading from usePlacesQuery():

useEffect(() => {
if (isLoading) {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
wasLoadingRef.current = true;
setPhase('loading');
return;
}

if (wasLoadingRef.current) {
setPhase('success');
hideTimerRef.current = setTimeout(() => {
setPhase('hidden');
wasLoadingRef.current = false;
}, SUCCESS_HOLD_MS);
}
}, [isLoading]);
isLoadingwasLoadingRef.currentNew phase
true(any)'loading' — also marks wasLoadingRef = true
falsetrue'success' — schedules hide after SUCCESS_HOLD_MS (1800ms)
falsefalseNo change (stays 'hidden')

wasLoadingRef gates the success state so the toast doesn't flash "Places loaded" on initial mount before any query has fired.

hideTimerRef is cleared whenever a new load starts, so a rapid pan that starts a new load mid-success snaps cleanly back to 'loading' without an orphan timer firing later.

Visual Morph (not Swap)

The earlier behavior swapped one toast for another — AnimatePresence mode="wait" keyed by phase, which unmounted "Loading places…" and remounted "Places loaded" as separate elements. The new behavior morphs the same pill in place.

<AnimatePresence>
{phase !== 'hidden' && (
<motion.div
key="toast"
layout
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="bg-black text-white ..."
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={`${phase}-icon`} layout ...>
{phase === 'loading' ? <Loader2 .../> : <Check .../> }
</motion.span>
<motion.span key={`${phase}-label`} layout ...>
{phase === 'loading' ? 'Loading places...' : 'Places loaded'}
</motion.span>
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>

Two layers of animation:

  1. Outer motion.div (key "toast") — stays mounted for the whole loading→success lifecycle. Has layout so its width animates smoothly when the inner content (label width) changes between phases.
  2. Inner AnimatePresence mode="popLayout" — swaps the icon and the label content. popLayout lets the outgoing element exit while the incoming element enters at the new layout position, so the icon scale-in spring overlaps with the label's slide/fade — no visual gap, no flicker.

The success icon uses a spring transition={{ type: 'spring', stiffness: 400, damping: 18 }} for a satisfying pop. The loading icon uses a plain easeOut.

Visual Style

Black pill, white text:

absolute top-6 left-1/2 -translate-x-1/2 z-50
flex items-center gap-2 rounded-md
bg-black px-4 py-2 text-white shadow-lg

The container <div> has pointer-events-none so the toast never blocks map interaction even while it's visible.

PhaseIconText
loading<Loader2 className="animate-spin" />"Loading places..."
successWhite check on bg-emerald-500 rounded square"Places loaded"

Adjacent Operations

  • usePlacesQuery() (src/components/providers/places-query-provider.tsx) — exposes isLoading: queryPlaces.isFetching || queryPlaces.isLoading. Both flags are tracked because isFetching covers background refetches that trigger after setReloadPlaces(true), and isLoading covers the initial query.
  • Bounds query trigger — Google Map's idle event fires after pan/zoom completes; the provider issues api.place.searchPlacesByBounds against the new bounds. Each cycle goes isLoading: false → true → false, which the toast renders as hidden → loading → success → hidden.
  • Forced refetchmapPlacesCtx.setReloadPlaces(true) (called by NameSearch.handlePlaceSelect) re-runs the query without a pan, which still drives the toast through the same loading/success cycle.
  • Reduced motion — the spring + scale animations here are subtle; for a stricter respect of prefers-reduced-motion see the marker animations in Map Markers.
  • Marker animationsUnifiedMarker and DotMarker independently animate their own enter/exit via framer-motion AnimatePresence in google-map.tsx. The toast does not block or coordinate with marker animations; it just describes the query lifecycle.