--- name: composable-svelte-navigation description: Navigation and animation patterns for Composable Svelte. Use when implementing modals, sheets, drawers, alerts, navigation flows, or component lifecycle animations. Covers state-driven navigation, PresentationState, parent observation, URL routing, and Motion One integration. --- # Composable Svelte Navigation & Animation This skill covers state-driven navigation patterns, PresentationState lifecycle animations, and URL routing integration. --- ## CRITICAL RULE ### Rule 3: State-Driven Animations Only **Principle**: Component lifecycle animations MUST use Motion One + PresentationState. NO CSS transitions for UI interactions. #### Animation Decision Tree ``` Does component have animation? ├─ NO → No animation system needed └─ YES → What kind? ├─ Infinite loop (spinner, shimmer) → CSS @keyframes ONLY ├─ Hover/focus/click → NO TRANSITION (instant visual feedback) └─ Lifecycle (appear/disappear/expand/collapse) → Motion One + PresentationState ``` #### ❌ WRONG - CSS Transitions ```css .button { transition: background-color 0.2s; /* ❌ REMOVED */ } .modal { transition: opacity 0.3s; /* ❌ Not state-driven, not testable */ } ``` #### ✅ CORRECT - State-Driven with Motion One ```typescript interface ModalState { content: Content | null; presentation: PresentationState; } // Reducer manages lifecycle case 'show': return [ { ...state, content, presentation: { status: 'presenting', content, duration: 0.3 } }, Effect.afterDelay(300, (d) => d({ type: 'presentation', event: { type: 'presentationCompleted' } })) ]; // Component executes animation $effect(() => { if (store.state.presentation.status === 'presenting') { animateModalIn(element).then(() => { store.dispatch({ type: 'presentation', event: { type: 'presentationCompleted' } }); }); } }); ``` **WHY**: State-driven animations are predictable, testable with TestStore, and composable with the navigation system. --- ## TREE-BASED NAVIGATION PATTERN ### Core Principle **Non-null state = presented, null = dismissed** This creates a navigation tree where each node can optionally present a child screen. ### State Structure ```typescript // Parent state interface AppState { items: Item[]; destination: DestinationState | null; // What to show } // Destination is enum of possible screens type DestinationState = | { type: 'addItem'; state: AddItemState } | { type: 'editItem'; state: EditItemState; itemId: string } | { type: 'confirmDelete'; state: ConfirmDeleteState; itemId: string }; // Child states interface AddItemState { name: string; quantity: number; } interface EditItemState { name: string; quantity: number; } interface ConfirmDeleteState { itemName: string; } ``` ### Actions ```typescript type AppAction = | { type: 'addButtonTapped' } | { type: 'editButtonTapped'; itemId: string } | { type: 'deleteButtonTapped'; itemId: string } | { type: 'destination'; action: PresentationAction }; type DestinationAction = | { type: 'addItem'; action: AddItemAction } | { type: 'editItem'; action: EditItemAction } | { type: 'confirmDelete'; action: ConfirmDeleteAction }; // PresentationAction wraps child actions type PresentationAction = | { type: 'presented'; action: A } | { type: 'dismiss' }; ``` --- ## IFLET COMPOSITION FOR OPTIONAL CHILDREN **When**: Child may or may not be present (modal, sheet, drawer, detail view) ### Basic Pattern ```typescript // Parent state interface AppState { items: Item[]; destination: AddItemState | null; // Optional child } // Parent actions type AppAction = | { type: 'addButtonTapped' } | { type: 'destination'; action: PresentationAction }; // Reducer import { ifLetPresentation } from '@composable-svelte/core'; case 'addButtonTapped': return [ { ...state, destination: { name: '', quantity: 0 } }, Effect.none() ]; case 'destination': { // Handle dismiss if (action.action.type === 'dismiss') { return [{ ...state, destination: null }, Effect.none()]; } // Compose child const [newState, effect] = ifLetPresentation( (s) => s.destination, (s, d) => ({ ...s, destination: d }), 'destination', (ca): AppAction => ({ type: 'destination', action: { type: 'presented', action: ca } }), addItemReducer )(state, action, deps); // Parent observes child completion if ('action' in action && action.action.type === 'presented' && action.action.action.type === 'saveButtonTapped') { const item = newState.destination!; return [ { ...newState, destination: null, // Dismiss items: [...newState.items, { id: crypto.randomUUID(), ...item }] }, effect ]; } return [newState, effect]; } ``` --- ## PARENT OBSERVATION PATTERN **Critical Pattern**: Parent can observe child actions to react to completion, cancellation, or other child events. ### Example: Observing Save/Cancel ```typescript case 'destination': { // Handle dismiss if (action.action.type === 'dismiss') { return [{ ...state, destination: null }, Effect.none()]; } // Route to child reducer based on destination type let newState = state; let effect: Effect = Effect.none(); if (state.destination?.type === 'addItem' && 'action' in action && action.action.type === 'presented') { const [childState, childEffect] = addItemReducer( state.destination.state, action.action.action, deps ); newState = { ...state, destination: { type: 'addItem', state: childState } }; effect = Effect.map(childEffect, (childAction): AppAction => ({ type: 'destination', action: { type: 'presented', action: { type: 'addItem', action: childAction } } })); // Observe child completion if (action.action.action.type === 'saveButtonTapped') { return [ { ...newState, destination: null, items: [...newState.items, { id: crypto.randomUUID(), name: childState.name, quantity: childState.quantity }] }, effect ]; } // Observe child cancellation if (action.action.action.type === 'cancelButtonTapped') { return [ { ...newState, destination: null }, effect ]; } } // Similar for editItem and confirmDelete... return [newState, effect]; } ``` --- ## SCOPING STORES FOR NAVIGATION ### scopeToDestination Pattern ```typescript import { scopeToDestination } from '@composable-svelte/core'; // In component const addItemStore = $derived( scopeToDestination(store, 'destination', 'addItem') ); {#if addItemStore} !open && addItemStore.dismiss()}> {/if} ``` **What it does**: - Returns scoped store when destination matches the specified type - Returns `null` when destination is null or different type - Scoped store has `dismiss()` method that dispatches dismiss action --- ## PRESENTATIONSTATE LIFECYCLE ### The Lifecycle ``` idle → presenting → presented → dismissing → idle ↑ ↓ ↓ ↓ ↑ └────────┴───────────┴───────────┴─────────┘ ``` ### PresentationState Type ```typescript type PresentationState = | { status: 'idle' } | { status: 'presenting'; content: Content; duration: number } | { status: 'presented'; content: Content } | { status: 'dismissing'; content: Content; duration: number }; type PresentationEvent = | { type: 'presentationCompleted' } | { type: 'dismissalCompleted' }; ``` ### Complete Animated Modal Example ```typescript // State interface ModalState { content: ModalContent | null; presentation: PresentationState; } interface ModalContent { title: string; message: string; } // Actions type ModalAction = | { type: 'show'; content: ModalContent } | { type: 'hide' } | { type: 'presentation'; event: PresentationEvent }; // Reducer const modalReducer: Reducer = (state, action) => { switch (action.type) { case 'show': // Guard: Don't show if already presenting/presented if (state.presentation.status !== 'idle') { return [state, Effect.none()]; } return [ { ...state, content: action.content, presentation: { status: 'presenting', content: action.content, duration: 0.3 } }, Effect.afterDelay(300, (d) => d({ type: 'presentation', event: { type: 'presentationCompleted' } })) ]; case 'presentation': if (action.event.type === 'presentationCompleted' && state.presentation.status === 'presenting') { return [ { ...state, presentation: { status: 'presented', content: state.presentation.content } }, Effect.none() ]; } if (action.event.type === 'dismissalCompleted' && state.presentation.status === 'dismissing') { return [ { ...state, content: null, presentation: { status: 'idle' } }, Effect.none() ]; } return [state, Effect.none()]; case 'hide': // Guard: Can only hide from 'presented' if (state.presentation.status !== 'presented') { return [state, Effect.none()]; } return [ { ...state, presentation: { status: 'dismissing', content: state.presentation.content, duration: 0.2 } }, Effect.afterDelay(200, (d) => d({ type: 'presentation', event: { type: 'dismissalCompleted' } })) ]; default: const _never: never = action; return [state, Effect.none()]; } }; // Component {#if $store.content} {/if} ``` --- ## MOTION ONE ANIMATION SYSTEM ### Animation Helpers ```typescript import { animateModalIn, animateModalOut, animateSheetIn, animateSheetOut, animateAccordionExpand, animateAccordionCollapse } from '@composable-svelte/core/animation'; // Usage $effect(() => { if ($store.presentation.status === 'presenting') { animateModalIn(element).then(() => { store.dispatch({ type: 'presentation', event: { type: 'presentationCompleted' } }); }); } }); ``` ### When to Use Motion One (REQUIRED) 1. **Component Lifecycle Animations**: Modal/Dialog fade/scale, Dropdown appear/disappear, Sheet slide in/out 2. **Expand/Collapse Animations**: Accordion items, Collapsible sections, height transitions 3. **Toast/Alert Animations**: Slide in from edge, Notification animations 4. **Navigation Animations**: Page transitions, Stack push/pop, route changes ### Animation Helpers Reference ```typescript // Modal animations (fade + scale) animateModalIn(element: HTMLElement): Promise animateModalOut(element: HTMLElement): Promise // Sheet animations (slide from bottom) animateSheetIn(element: HTMLElement): Promise animateSheetOut(element: HTMLElement): Promise // Drawer animations (slide from side) animateDrawerIn(element: HTMLElement, side: 'left' | 'right'): Promise animateDrawerOut(element: HTMLElement, side: 'left' | 'right'): Promise // Accordion animations (height) animateAccordionExpand(element: HTMLElement): Promise animateAccordionCollapse(element: HTMLElement): Promise // Dropdown animations (fade + slide) animateDropdownIn(element: HTMLElement): Promise animateDropdownOut(element: HTMLElement): Promise ``` ### CSS @keyframes (EXCEPTIONS ONLY) ```css /* ✅ ALLOWED - Infinite loop */ @keyframes spin { to { transform: rotate(360deg); } } .spinner { animation: spin 1s linear infinite; } /* ✅ ALLOWED - Shimmer effect */ @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } .skeleton { animation: shimmer 1.5s infinite; } ``` **CSS Animations**: - ✅ **Allowed**: Infinite loops (Spinner, Skeleton shimmer effects, Progress indicators) - ❌ **Prohibited**: Hover states, Focus states, Click/Active states - ❌ **Prohibited**: Any lifecycle animations (appearing, disappearing, expanding, collapsing) --- ## URL ROUTING INTEGRATION ### Pattern: Sync Browser History with State URL routing is state synchronization, not a separate navigation system. Use the router's pure functions for serialization/parsing. ```typescript import { syncBrowserHistory } from '@composable-svelte/core/routing'; // In client hydration syncBrowserHistory(store, { serializers: serializerConfig.serializers, parsers: parserConfig.parsers, // Map state → destination for URL serialization getDestination: (state) => { if (state.selectedPostId !== null) { return { type: 'post' as const, state: { postId: state.selectedPostId } }; } return null; }, // Map destination → action for back/forward navigation destinationToAction: (dest) => { if (dest?.type === 'post') { return { type: 'selectPost', postId: dest.state.postId }; } return null; } }); ``` ### Server-Side URL Parsing ```typescript import { parseDestination } from './routing'; // In server route handler async function renderApp(request: any, reply: any) { const posts = await loadPosts(); const path = request.url; const requestedPostId = parsePostFromURL(path, posts[0]?.id || 1); const store = createStore({ initialState: { ...initialState, posts, selectedPostId: requestedPostId, meta: computeMetaForPost(posts.find(p => p.id === requestedPostId)) }, reducer: appReducer, dependencies: {} }); const html = renderToHTML(App, { store }); reply.type('text/html').send(html); } ``` ### Router Configuration ```typescript import { createRouter } from '@composable-svelte/core/routing'; // Define destination types type Destination = | { type: 'post'; state: { postId: number } }; // Create router with patterns const router = createRouter() .route('/', null) .route('/posts/:postId', (params) => ({ type: 'post', state: { postId: parseInt(params.postId, 10) } })) .build(); // Use for parsing const destination = router.parse('/posts/42'); // { type: 'post', state: { postId: 42 } } // Use for serialization const path = router.serialize({ type: 'post', state: { postId: 42 } }); // '/posts/42' ``` --- ## NAVIGATION COMPONENTS HOW-TO These components are from the shadcn-svelte component library. See **composable-svelte-components** skill for full reference. ### Modal - Full-Screen Overlay **When to use**: Primary action, form submission, important warnings ```svelte {#if modalStore} !open && modalStore.dismiss()} > {/if} ``` ### Sheet - Bottom Drawer **When to use**: Mobile-first UIs, filters, settings panels ```svelte {#if sheetStore} !open && sheetStore.dismiss()} > {/if} ``` ### Drawer - Side Panel **When to use**: Navigation menus, sidebars, settings ```svelte {#if drawerStore} !open && drawerStore.dismiss()} > {/if} ``` ### Alert - Confirmation Dialog **When to use**: Destructive actions, confirmations ```svelte {#if confirmStore} !open && confirmStore.dismiss()} > Delete Item? This action cannot be undone. {/if} ``` ### Popover - Contextual Menu **When to use**: Dropdown menus, tooltips, context menus ```svelte store.dispatch({ type: 'toggleMenu', open })}> ``` --- ## COMPLETE EXAMPLES ### Example 1: Modal with Edit Form ```typescript // State interface AppState { user: User | null; editProfile: EditProfileState | null; } interface EditProfileState { name: string; email: string; bio: string; } // Actions type AppAction = | { type: 'editProfileTapped' } | { type: 'destination'; action: PresentationAction }; type EditProfileAction = | { type: 'nameChanged'; name: string } | { type: 'emailChanged'; email: string } | { type: 'bioChanged'; bio: string } | { type: 'saveButtonTapped' } | { type: 'cancelButtonTapped' }; // Reducer case 'editProfileTapped': return [ { ...state, editProfile: { name: state.user?.name || '', email: state.user?.email || '', bio: state.user?.bio || '' } }, Effect.none() ]; case 'destination': { if (action.action.type === 'dismiss') { return [{ ...state, editProfile: null }, Effect.none()]; } const [childState, childEffect] = editProfileReducer( state.editProfile!, action.action.action, deps ); const newState = { ...state, editProfile: childState }; const effect = Effect.map(childEffect, (ca): AppAction => ({ type: 'destination', action: { type: 'presented', action: ca } })); // Observe save if (action.action.action.type === 'saveButtonTapped') { return [ { ...newState, editProfile: null, user: { ...state.user!, name: childState.name, email: childState.email, bio: childState.bio } }, Effect.batch( effect, Effect.run(async (d) => { await api.updateProfile(childState); d({ type: 'profileUpdated' }); }) ) ]; } // Observe cancel if (action.action.action.type === 'cancelButtonTapped') { return [{ ...newState, editProfile: null }, effect]; } return [newState, effect]; } // Component {#if editProfileStore} !open && editProfileStore.dismiss()} > {/if} ``` ### Example 2: Sheet with Animated Filters ```typescript // State with PresentationState interface AppState { items: Item[]; filters: FilterState | null; presentation: PresentationState; } interface FilterState { category: string; priceRange: [number, number]; sortBy: 'name' | 'price' | 'date'; } // Actions type AppAction = | { type: 'showFilters' } | { type: 'hideFilters' } | { type: 'presentation'; event: PresentationEvent } | { type: 'destination'; action: PresentationAction }; // Reducer with animation lifecycle case 'showFilters': if (state.presentation.status !== 'idle') { return [state, Effect.none()]; } const initialFilters = { category: 'all', priceRange: [0, 1000], sortBy: 'name' }; return [ { ...state, filters: initialFilters, presentation: { status: 'presenting', content: initialFilters, duration: 0.3 } }, Effect.afterDelay(300, (d) => d({ type: 'presentation', event: { type: 'presentationCompleted' } })) ]; case 'presentation': if (action.event.type === 'presentationCompleted') { return [ { ...state, presentation: { status: 'presented', content: state.presentation.content } }, Effect.none() ]; } if (action.event.type === 'dismissalCompleted') { return [ { ...state, filters: null, presentation: { status: 'idle' } }, Effect.none() ]; } return [state, Effect.none()]; // Component with animation {#if filterStore} !open && filterStore.dismiss()} >
{/if} ``` --- ## COMMON ANTI-PATTERNS ### 1. Forgetting to Handle Dismiss #### ❌ WRONG ```typescript case 'destination': { // Only handles child actions, not dismiss const [newState, effect] = ifLetPresentation(...)(state, action, deps); return [newState, effect]; } ``` #### ✅ CORRECT ```typescript case 'destination': { if (action.action.type === 'dismiss') { return [{ ...state, destination: null }, Effect.none()]; } const [newState, effect] = ifLetPresentation(...)(state, action, deps); return [newState, effect]; } ``` **WHY**: PresentationAction includes dismiss. Parent must handle it to close modal/sheet. --- ### 2. Not Using PresentationState for Animations #### ❌ WRONG ```typescript interface State { showModal: boolean; // Just a boolean, no animation lifecycle } ``` #### ✅ CORRECT ```typescript interface State { content: Content | null; presentation: PresentationState; // Full lifecycle } ``` **WHY**: PresentationState tracks animation lifecycle (presenting → presented → dismissing), enabling state-driven animations. --- ### 3. CSS Transitions for Lifecycle Animations #### ❌ WRONG ```css .modal { transition: opacity 0.3s; } ``` #### ✅ CORRECT ```typescript $effect(() => { if ($store.presentation.status === 'presenting') { animateModalIn(element).then(() => { store.dispatch({ type: 'presentation', event: { type: 'presentationCompleted' } }); }); } }); ``` **WHY**: State-driven animations are testable, predictable, and composable. --- ## DECISION TOOLS ### Navigation Component Selection ``` What kind of overlay? │ ├─ Full-screen important action → Modal ├─ Bottom panel (mobile-first) → Sheet ├─ Side panel (navigation/settings) → Drawer ├─ Quick confirmation (yes/no) → Alert └─ Contextual menu (dropdown) → Popover ``` ### Animation Decision Tree ``` Does component animate? ├─ NO → No animation system needed └─ YES → What kind? ├─ Infinite loop (spinner, shimmer) → CSS @keyframes ONLY ├─ Hover/focus/click → NO TRANSITION (instant visual feedback) └─ Lifecycle (appear/disappear/expand/collapse) → Motion One + PresentationState ``` --- ## CHECKLISTS ### Navigation Feature Checklist - [ ] 1. Add optional destination field to state (`DestinationState | null`) - [ ] 2. Use discriminated union if multiple destination types - [ ] 3. Define PresentationAction wrapper - [ ] 4. Handle dismiss action (set destination to null) - [ ] 5. Use ifLetPresentation for child composition - [ ] 6. Parent observes child completion actions - [ ] 7. Use scopeToDestination in component - [ ] 8. Add PresentationState if animations needed ### Animation Feature Checklist - [ ] 1. Add PresentationState field to state - [ ] 2. Add presentation actions (show, hide, presentation events) - [ ] 3. Add guards to prevent invalid transitions - [ ] 4. Use Motion One helpers (animateModalIn, etc.) - [ ] 5. Dispatch presentation events after animation completes - [ ] 6. Handle presentationCompleted and dismissalCompleted - [ ] 7. Test animation lifecycle with TestStore (see composable-svelte-testing skill) --- ## TEMPLATES ### Navigation with Modal Template ```typescript // types.ts interface AppState { items: Item[]; destination: AddItemState | null; } interface AddItemState { name: string; quantity: number; } type AppAction = | { type: 'addButtonTapped' } | { type: 'destination'; action: PresentationAction }; type AddItemAction = | { type: 'nameChanged'; name: string } | { type: 'quantityChanged'; quantity: number } | { type: 'saveButtonTapped' }; // reducer.ts case 'addButtonTapped': return [ { ...state, destination: { name: '', quantity: 0 } }, Effect.none() ]; case 'destination': { if (action.action.type === 'dismiss') { return [{ ...state, destination: null }, Effect.none()]; } const [newState, effect] = ifLetPresentation( (s) => s.destination, (s, d) => ({ ...s, destination: d }), 'destination', (ca): AppAction => ({ type: 'destination', action: { type: 'presented', action: ca } }), addItemReducer )(state, action, deps); if ('action' in action && action.action.type === 'presented' && action.action.action.type === 'saveButtonTapped') { return [ { ...newState, destination: null, items: [...newState.items, { id: crypto.randomUUID(), ...newState.destination! }] }, effect ]; } return [newState, effect]; } // App.svelte {#if addItemStore} !open && addItemStore.dismiss()}> {/if} ``` --- ## SUMMARY This skill covers navigation and animation patterns for Composable Svelte: 1. **Critical Rule**: State-driven animations only (Motion One + PresentationState) 2. **Tree-Based Navigation**: Non-null = presented, null = dismissed 3. **ifLet Composition**: For optional children (modals, sheets, drawers) 4. **Parent Observation**: React to child completion/cancellation 5. **PresentationState Lifecycle**: idle → presenting → presented → dismissing → idle 6. **Motion One Integration**: Animation helpers for all lifecycle animations 7. **URL Routing**: Sync browser history with state 8. **Navigation Components**: Modal, Sheet, Drawer, Alert, Popover **Remember**: All component lifecycle animations MUST use Motion One + PresentationState. NO CSS transitions for UI interactions. For core architecture patterns, see **composable-svelte-core** skill. For testing navigation flows, see **composable-svelte-testing** skill. For component library reference, see **composable-svelte-components** skill. For SSR with navigation, see **composable-svelte-ssr** skill.