| 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;
}
.modal {
transition: opacity 0.3s;
}
✅ CORRECT - State-Driven with Motion One
interface ModalState {
content: Content | null;
presentation: PresentationState<Content>;
}
case 'show':
return [
{
...state,
content,
presentation: { status: 'presenting', content, duration: 0.3 }
},
Effect.afterDelay(300, (d) => d({
type: 'presentation',
event: { type: 'presentationCompleted' }
}))
];
$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
interface AppState {
items: Item[];
destination: DestinationState | null;
}
type DestinationState =
| { type: 'addItem'; state: AddItemState }
| { type: 'editItem'; state: EditItemState; itemId: string }
| { type: 'confirmDelete'; state: ConfirmDeleteState; itemId: string };
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 };
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
interface AppState {
items: Item[];
destination: AddItemState | null;
}
type AppAction =
| { type: 'addButtonTapped' }
| { type: 'destination'; action: PresentationAction<AddItemAction> };
import { ifLetPresentation } from '@composable-svelte/core';
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 }),
,
(ca): ({ : , : { : , : ca } }),
addItemReducer
)(state, action, deps);
( action &&
action.. === &&
action... === ) {
item = newState.!;
[
{
...newState,
: ,
: [...newState., { : crypto.(), ...item }]
},
effect
];
}
[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': {
if (action.action.type === 'dismiss') {
return [{ ...state, destination: null }, Effect.none()];
}
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: , : childAction } }
}));
(action... === ) {
[
{
...newState,
: ,
: [...newState., {
: crypto.(),
: childState.,
: childState.
}]
},
effect
];
}
(action... === ) {
[
{ ...newState, : },
effect
];
}
}
[newState, effect];
}
SCOPING STORES FOR NAVIGATION
scopeToDestination Pattern
import { scopeToDestination } from '@composable-svelte/core';
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
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
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
interface ModalState {
content: ModalContent | null;
presentation: PresentationState<ModalContent>;
}
interface ModalContent {
title: string;
message: string;
}
type ModalAction =
| { type: 'show'; content: ModalContent }
| { type: 'hide' }
| { type: 'presentation'; event: PresentationEvent };
const modalReducer: Reducer<ModalState, ModalAction> = (state, action) => {
switch (action.type) {
case 'show':
if (state.presentation.status !== 'idle') {
return [state, Effect.none()];
}
return [
{
...state,
content: action.content,
: {
: ,
: action.,
:
}
},
.(, ({
: ,
: { : }
}))
];
:
(action.. === &&
state.. === ) {
[
{
...state,
: {
: ,
: state..
}
},
.()
];
}
(action.. === &&
state.. === ) {
[
{
...state,
: ,
: { : }
},
.()
];
}
[state, .()];
:
(state.. !== ) {
[state, .()];
}
[
{
...state,
: {
: ,
: state..,
:
}
},
.(, ({
: ,
: { : }
}))
];
:
: = action;
[state, .()];
}
};
{# $store.}
<div =>
</div>
{/}
MOTION ONE ANIMATION SYSTEM
Animation Helpers
import {
animateModalIn,
animateModalOut,
animateSheetIn,
animateSheetOut,
animateAccordionExpand,
animateAccordionCollapse
} from '@composable-svelte/core/animation';
$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
animateModalIn(element: HTMLElement): Promise<void>
animateModalOut(element: HTMLElement): Promise<void>
animateSheetIn(element: HTMLElement): Promise<void>
animateSheetOut(element: HTMLElement): Promise<void>
animateDrawerIn(element: HTMLElement, side: 'left' | 'right'): Promise<void>
animateDrawerOut(element: HTMLElement, side: 'left' | 'right'): Promise<void>
animateAccordionExpand(element: HTMLElement): Promise<void>
animateAccordionCollapse(element: HTMLElement): Promise<void>
(: ): <>
(: ): <>
CSS @keyframes (EXCEPTIONS ONLY)
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 1s linear infinite;
}
@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';
syncBrowserHistory(store, {
serializers: serializerConfig.serializers,
parsers: parserConfig.parsers,
getDestination: (state) => {
if (state.selectedPostId !== null) {
return { type: 'post' as const, state: { postId: state.selectedPostId } };
}
return null;
},
destinationToAction: (dest) => {
if (dest?.type === 'post') {
return { type: 'selectPost', postId: dest.state.postId };
}
return null;
}
});
Server-Side URL Parsing
import { parseDestination } from './routing';
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';
type Destination =
| { type: 'post'; state: { postId: number } };
const router = createRouter<Destination>()
.route('/', null)
.route('/posts/:postId', (params) => ({
type: 'post',
state: { postId: parseInt(params.postId, 10) }
}))
.build();
const destination = router.parse('/posts/42');
const path = router.serialize({ type: 'post', state: { postId: 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
interface AppState {
user: User | null;
editProfile: EditProfileState | null;
}
interface EditProfileState {
name: string;
email: string;
bio: string;
}
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' };
case 'editProfileTapped':
return [
{
...state,
editProfile: {
name: state.user?.name || ,
: state.?. || ,
: state.?. ||
}
},
.()
];
: {
(action.. === ) {
[{ ...state, : }, .()];
}
[childState, childEffect] = (
state.!,
action..,
deps
);
newState = { ...state, : childState };
effect = .(childEffect, (ca): ({
: ,
: { : , : ca }
}));
(action... === ) {
[
{
...newState,
: ,
: {
...state.!,
: childState.,
: childState.,
: childState.
}
},
.(
effect,
.( (d) => {
api.(childState);
({ : });
})
)
];
}
(action... === ) {
[{ ...newState, : }, effect];
}
[newState, effect];
}
<script lang=>
{ , } ;
{ scopeToDestination } ;
editProfileStore = $derived(
(store, )
);
</script>
{# editProfileStore}
<
open={}
onOpenChange={ !open && editProfileStore.()}
>
</>
{/}
Example 2: Sheet with Animated Filters
interface AppState {
items: Item[];
filters: FilterState | null;
presentation: PresentationState<FilterState>;
}
interface FilterState {
category: string;
priceRange: [number, number];
sortBy: 'name' | 'price' | 'date';
}
type AppAction =
| { type: 'showFilters' }
| { type: 'hideFilters' }
| { type: 'presentation'; event: PresentationEvent }
| { type: 'destination'; action: PresentationAction<FilterAction> };
case 'showFilters':
if (state.presentation.status !== 'idle') {
return [state, Effect.none()];
}
const initialFilters = { category: 'all', priceRange: [, ], : };
[
{
...state,
: initialFilters,
: { : , : initialFilters, : }
},
.(, ({
: ,
: { : }
}))
];
:
(action.. === ) {
[
{ ...state, : { : , : state.. } },
.()
];
}
(action.. === ) {
[
{ ...state, : , : { : } },
.()
];
}
[state, .()];
{# filterStore}
<
open={}
onOpenChange={ !open && filterStore.()}
>
</>
{/}
COMMON ANTI-PATTERNS
1. Forgetting to Handle Dismiss
❌ WRONG
case 'destination': {
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;
}
✅ CORRECT
interface State {
content: Content | null;
presentation: PresentationState<Content>;
}
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
Animation Feature Checklist
TEMPLATES
Navigation with Modal Template
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' };
case 'addButtonTapped':
return [
{ ...state, destination: { name: '', quantity: 0 } },
Effect.none()
];
case 'destination': {
if (action.action.type === 'dismiss') {
return [{ ...state, : }, .()];
}
[newState, effect] = (
s.,
({ ...s, : d }),
,
(ca): ({ : , : { : , : ca } }),
addItemReducer
)(state, action, deps);
( action &&
action.. === &&
action... === ) {
[
{
...newState,
: ,
: [...newState., {
: crypto.(),
...newState.!
}]
},
effect
];
}
[newState, effect];
}
<script lang=>
{ } ;
{ scopeToDestination } ;
addItemStore = $derived((store, ));
</script>
{# addItemStore}
< open={} onOpenChange={ !open && addItemStore.()}>
</>
{/}
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.