| 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
.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
nullwhen 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
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
-
- Add optional destination field to state (
DestinationState | null)
- Add optional destination field to state (
-
- Use discriminated union if multiple destination types
-
- Define PresentationAction wrapper
-
- Handle dismiss action (set destination to null)
-
- Use ifLetPresentation for child composition
-
- Parent observes child completion actions
-
- Use scopeToDestination in component
-
- Add PresentationState if animations needed
Animation Feature Checklist
-
- Add PresentationState field to state
-
- Add presentation actions (show, hide, presentation events)
-
- Add guards to prevent invalid transitions
-
- Use Motion One helpers (animateModalIn, etc.)
-
- Dispatch presentation events after animation completes
-
- Handle presentationCompleted and dismissalCompleted
-
- 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.