Claude-skill-registry composable-svelte-navigation
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.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/composable-svelte-navigation" ~/.claude/skills/majiayu000-claude-skill-registry-composable-svelte-navigation && rm -rf "$T"
skills/data/composable-svelte-navigation/SKILL.mdComposable 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
.button { transition: background-color 0.2s; /* ❌ REMOVED */ } .modal { transition: opacity 0.3s; /* ❌ Not state-driven, not testable */ }
✅ CORRECT - State-Driven with Motion One
interface ModalState { content: Content | null; presentation: PresentationState<Content>; } // 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
// 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
type AppAction = | { type: 'addButtonTapped' } | { type: 'editButtonTapped'; itemId: string } | { type: 'deleteButtonTapped'; itemId: string } | { type: 'destination'; action: PresentationAction<DestinationAction> }; type DestinationAction = | { type: 'addItem'; action: AddItemAction } | { type: 'editItem'; action: EditItemAction } | { type: 'confirmDelete'; action: ConfirmDeleteAction }; // PresentationAction wraps child actions type PresentationAction<A> = | { 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
// Parent state interface AppState { items: Item[]; destination: AddItemState | null; // Optional child } // Parent actions type AppAction = | { type: 'addButtonTapped' } | { type: 'destination'; action: PresentationAction<AddItemAction> }; // 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
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<AppAction> = 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
import { scopeToDestination } from '@composable-svelte/core'; // In component const addItemStore = $derived( scopeToDestination(store, 'destination', 'addItem') ); {#if addItemStore} <Modal open={true} onOpenChange={(open) => !open && addItemStore.dismiss()}> <AddItemForm store={addItemStore} /> </Modal> {/if}
What it does:
- Returns scoped store when destination matches the specified type
- Returns
when destination is null or different typenull - Scoped store has
method that dispatches dismiss actiondismiss()
PRESENTATIONSTATE LIFECYCLE
The Lifecycle
idle → presenting → presented → dismissing → idle ↑ ↓ ↓ ↓ ↑ └────────┴───────────┴───────────┴─────────┘
PresentationState Type
type PresentationState<Content> = | { 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
// State interface ModalState { content: ModalContent | null; presentation: PresentationState<ModalContent>; } interface ModalContent { title: string; message: string; } // Actions type ModalAction = | { type: 'show'; content: ModalContent } | { type: 'hide' } | { type: 'presentation'; event: PresentationEvent }; // Reducer const modalReducer: Reducer<ModalState, ModalAction> = (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 <script lang="ts"> import { animate } from 'motion'; let dialogElement: HTMLElement; $effect(() => { if ($store.presentation.status === 'presenting' && dialogElement) { animate( dialogElement, { opacity: [0, 1], scale: [0.95, 1] }, { duration: 0.3, easing: 'ease-out' } ).finished.then(() => { store.dispatch({ type: 'presentation', event: { type: 'presentationCompleted' } }); }); } if ($store.presentation.status === 'dismissing' && dialogElement) { animate( dialogElement, { opacity: [1, 0], scale: [1, 0.95] }, { duration: 0.2, easing: 'ease-in' } ).finished.then(() => { store.dispatch({ type: 'presentation', event: { type: 'dismissalCompleted' } }); }); } }); </script> {#if $store.content} <div class="modal-backdrop"> <dialog bind:this={dialogElement}> <h2>{$store.content.title}</h2> <p>{$store.content.message}</p> <button onclick={() => store.dispatch({ type: 'hide' })}> Close </button> </dialog> </div> {/if}
MOTION ONE ANIMATION SYSTEM
Animation Helpers
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)
- Component Lifecycle Animations: Modal/Dialog fade/scale, Dropdown appear/disappear, Sheet slide in/out
- Expand/Collapse Animations: Accordion items, Collapsible sections, height transitions
- Toast/Alert Animations: Slide in from edge, Notification animations
- Navigation Animations: Page transitions, Stack push/pop, route changes
Animation Helpers Reference
// Modal animations (fade + scale) animateModalIn(element: HTMLElement): Promise<void> animateModalOut(element: HTMLElement): Promise<void> // Sheet animations (slide from bottom) animateSheetIn(element: HTMLElement): Promise<void> animateSheetOut(element: HTMLElement): Promise<void> // Drawer animations (slide from side) animateDrawerIn(element: HTMLElement, side: 'left' | 'right'): Promise<void> animateDrawerOut(element: HTMLElement, side: 'left' | 'right'): Promise<void> // Accordion animations (height) animateAccordionExpand(element: HTMLElement): Promise<void> animateAccordionCollapse(element: HTMLElement): Promise<void> // Dropdown animations (fade + slide) animateDropdownIn(element: HTMLElement): Promise<void> animateDropdownOut(element: HTMLElement): Promise<void>
CSS @keyframes (EXCEPTIONS ONLY)
/* ✅ 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.
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
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
import { createRouter } from '@composable-svelte/core/routing'; // Define destination types type Destination = | { type: 'post'; state: { postId: number } }; // Create router with patterns const router = createRouter<Destination>() .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
<script lang="ts"> import { Modal } from '@composable-svelte/core/components'; import { scopeToDestination } from '@composable-svelte/core'; const modalStore = $derived(scopeToDestination(store, 'destination', 'addItem')); </script> {#if modalStore} <Modal open={true} onOpenChange={(open) => !open && modalStore.dismiss()} > <ModalContent store={modalStore} /> </Modal> {/if}
Sheet - Bottom Drawer
When to use: Mobile-first UIs, filters, settings panels
<script lang="ts"> import { Sheet } from '@composable-svelte/core/components'; const sheetStore = $derived(scopeToDestination(store, 'destination', 'filters')); </script> {#if sheetStore} <Sheet open={true} onOpenChange={(open) => !open && sheetStore.dismiss()} > <SheetContent store={sheetStore} /> </Sheet> {/if}
Drawer - Side Panel
When to use: Navigation menus, sidebars, settings
<script lang="ts"> import { Drawer } from '@composable-svelte/core/components'; const drawerStore = $derived(scopeToDestination(store, 'destination', 'menu')); </script> {#if drawerStore} <Drawer side="left" open={true} onOpenChange={(open) => !open && drawerStore.dismiss()} > <DrawerContent store={drawerStore} /> </Drawer> {/if}
Alert - Confirmation Dialog
When to use: Destructive actions, confirmations
<script lang="ts"> import { Alert, AlertTitle, AlertDescription, AlertActions, Button } from '@composable-svelte/core/components'; const confirmStore = $derived(scopeToDestination(store, 'destination', 'confirmDelete')); </script> {#if confirmStore} <Alert open={true} onOpenChange={(open) => !open && confirmStore.dismiss()} > <AlertTitle>Delete Item?</AlertTitle> <AlertDescription>This action cannot be undone.</AlertDescription> <AlertActions> <Button onclick={() => confirmStore.dismiss()}>Cancel</Button> <Button variant="destructive" onclick={() => confirmStore.dispatch({ type: 'confirm' })}> Delete </Button> </AlertActions> </Alert> {/if}
Popover - Contextual Menu
When to use: Dropdown menus, tooltips, context menus
<script lang="ts"> import { Popover, PopoverTrigger, PopoverContent } from '@composable-svelte/core/components'; </script> <Popover open={$store.showMenu} onOpenChange={(open) => store.dispatch({ type: 'toggleMenu', open })}> <PopoverTrigger> <Button>Options</Button> </PopoverTrigger> <PopoverContent> <button onclick={() => store.dispatch({ type: 'edit' })}>Edit</button> <button onclick={() => store.dispatch({ type: 'delete' })}>Delete</button> </PopoverContent> </Popover>
COMPLETE EXAMPLES
Example 1: Modal with Edit Form
// 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<EditProfileAction> }; 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 <script lang="ts"> import { Modal, Button } from '@composable-svelte/core/components'; import { scopeToDestination } from '@composable-svelte/core'; const editProfileStore = $derived( scopeToDestination(store, 'editProfile') ); </script> <Button onclick={() => store.dispatch({ type: 'editProfileTapped' })}> Edit Profile </Button> {#if editProfileStore} <Modal open={true} onOpenChange={(open) => !open && editProfileStore.dismiss()} > <EditProfileForm store={editProfileStore} /> </Modal> {/if}
Example 2: Sheet with Animated Filters
// State with PresentationState interface AppState { items: Item[]; filters: FilterState | null; presentation: PresentationState<FilterState>; } 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<FilterAction> }; // 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 <script lang="ts"> import { Sheet } from '@composable-svelte/core/components'; import { animateSheetIn, animateSheetOut } from '@composable-svelte/core/animation'; let sheetElement: HTMLElement; $effect(() => { if ($store.presentation.status === 'presenting' && sheetElement) { animateSheetIn(sheetElement).then(() => { store.dispatch({ type: 'presentation', event: { type: 'presentationCompleted' } }); }); } if ($store.presentation.status === 'dismissing' && sheetElement) { animateSheetOut(sheetElement).then(() => { store.dispatch({ type: 'presentation', event: { type: 'dismissalCompleted' } }); }); } }); const filterStore = $derived(scopeToDestination(store, 'filters')); </script> {#if filterStore} <Sheet open={true} onOpenChange={(open) => !open && filterStore.dismiss()} > <div bind:this={sheetElement}> <FilterForm store={filterStore} /> </div> </Sheet> {/if}
COMMON ANTI-PATTERNS
1. Forgetting to Handle Dismiss
❌ WRONG
case 'destination': { // Only handles child actions, not dismiss const [newState, effect] = ifLetPresentation(...)(state, action, deps); return [newState, effect]; }
✅ CORRECT
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
interface State { showModal: boolean; // Just a boolean, no animation lifecycle }
✅ CORRECT
interface State { content: Content | null; presentation: PresentationState<Content>; // Full lifecycle }
WHY: PresentationState tracks animation lifecycle (presenting → presented → dismissing), enabling state-driven animations.
3. CSS Transitions for Lifecycle Animations
❌ WRONG
.modal { transition: opacity 0.3s; }
✅ CORRECT
$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
// types.ts interface AppState { items: Item[]; destination: AddItemState | null; } interface AddItemState { name: string; quantity: number; } type AppAction = | { type: 'addButtonTapped' } | { type: 'destination'; action: PresentationAction<AddItemAction> }; 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 <script lang="ts"> import { Modal } from '@composable-svelte/core/components'; import { scopeToDestination } from '@composable-svelte/core'; const addItemStore = $derived(scopeToDestination(store, 'destination')); </script> <Button onclick={() => store.dispatch({ type: 'addButtonTapped' })}> Add Item </Button> {#if addItemStore} <Modal open={true} onOpenChange={(open) => !open && addItemStore.dismiss()}> <AddItemForm store={addItemStore} /> </Modal> {/if}
SUMMARY
This skill covers navigation and animation patterns for Composable Svelte:
- Critical Rule: State-driven animations only (Motion One + PresentationState)
- Tree-Based Navigation: Non-null = presented, null = dismissed
- ifLet Composition: For optional children (modals, sheets, drawers)
- Parent Observation: React to child completion/cancellation
- PresentationState Lifecycle: idle → presenting → presented → dismissing → idle
- Motion One Integration: Animation helpers for all lifecycle animations
- URL Routing: Sync browser history with state
- 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.