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
loading→successin place, whichreact-hot-toastcan'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]);
isLoading | wasLoadingRef.current | New phase |
|---|---|---|
true | (any) | 'loading' — also marks wasLoadingRef = true |
false | true | 'success' — schedules hide after SUCCESS_HOLD_MS (1800ms) |
false | false | No 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:
- Outer
motion.div(key"toast") — stays mounted for the whole loading→success lifecycle. Haslayoutso its width animates smoothly when the inner content (label width) changes between phases. - Inner
AnimatePresence mode="popLayout"— swaps the icon and the label content.popLayoutlets 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.
| Phase | Icon | Text |
|---|---|---|
loading | <Loader2 className="animate-spin" /> | "Loading places..." |
success | White check on bg-emerald-500 rounded square | "Places loaded" |
Adjacent Operations
usePlacesQuery()(src/components/providers/places-query-provider.tsx) — exposesisLoading: queryPlaces.isFetching || queryPlaces.isLoading. Both flags are tracked becauseisFetchingcovers background refetches that trigger aftersetReloadPlaces(true), andisLoadingcovers the initial query.- Bounds query trigger — Google Map's
idleevent fires after pan/zoom completes; the provider issuesapi.place.searchPlacesByBoundsagainst the new bounds. Each cycle goesisLoading: false → true → false, which the toast renders ashidden → loading → success → hidden. - Forced refetch —
mapPlacesCtx.setReloadPlaces(true)(called byNameSearch.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-motionsee the marker animations in Map Markers. - Marker animations —
UnifiedMarkerandDotMarkerindependently animate their own enter/exit viaframer-motionAnimatePresenceingoogle-map.tsx. The toast does not block or coordinate with marker animations; it just describes the query lifecycle.