Agent Skills: Composable Svelte Navigation & Animation

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.

UncategorizedID: jonathanbelolo/composable-svelte/composable-svelte-navigation

Install this agent skill to your local

pnpm dlx add-skill https://github.com/jonathanbelolo/composable-svelte/tree/HEAD/.claude/skills/composable-svelte-navigation

Skill Files

Browse the full folder contents for composable-svelte-navigation.

Download Skill

Loading file tree…

.claude/skills/composable-svelte-navigation/SKILL.md

Skill Metadata

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 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

// 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)

  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

All Animation Helpers (26 functions)

import { animateModalIn, animateSheetIn, /* etc */ } from '@composable-svelte/core/animation';

// Modal (fade + scale)
animateModalIn(element), animateModalOut(element)
animateBackdropIn(element), animateBackdropOut(element)

// Sheet (slide from bottom)
animateSheetIn(element), animateSheetOut(element)

// Drawer (slide from side)
animateDrawerIn(element, side), animateDrawerOut(element, side)

// Alert (fade + scale, smaller)
animateAlertIn(element), animateAlertOut(element)

// Tooltip (fade + slight scale)
animateTooltipIn(element), animateTooltipOut(element)

// Toast (slide from edge)
animateToastIn(element), animateToastOut(element)

// Dropdown (fade + slide)
animateDropdownIn(element), animateDropdownOut(element)

// Sidebar (width expand/collapse)
animateSidebarExpand(element), animateSidebarCollapse(element)

// Popover (fade + scale)
animatePopoverIn(element), animatePopoverOut(element)

// NavigationStack (slide left/right)
animateStackPushIn(element), animateStackPushOut(element)
animateStackPopIn(element), animateStackPopOut(element)

// Accordion (height expand/collapse)
animateAccordionExpand(element), animateAccordionCollapse(element)

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}

STACK NAVIGATION

For multi-screen linear flows (wizards, drill-down navigation).

import { push, pop, popToRoot, setPath, handleStackAction, topScreen, rootScreen, canGoBack, stackDepth } from '@composable-svelte/core/navigation';

// State: array of screens
interface AppState {
  stack: Screen[];
}

// Push a new screen
const [newStack, effect] = push(state.stack, newScreen);

// Pop current screen
const [newStack, effect] = pop(state.stack);

// Pop to root
const [newStack, effect] = popToRoot(state.stack);

// Replace entire path
const [newStack, effect] = setPath(state.stack, [screen1, screen2]);

// Handle actions dispatched from screens
const [newState, effect] = handleStackAction(state, action, screenReducer, deps);

// Query helpers
const current = topScreen(state.stack);     // Last screen
const first = rootScreen(state.stack);      // First screen
const canPop = canGoBack(state.stack);      // stack.length > 1
const depth = stackDepth(state.stack);      // stack.length

NavigationStack Component

<script lang="ts">
  import { NavigationStack, AnimatedNavigationStack } from '@composable-svelte/core/navigation-components';
</script>

<!-- Basic (no animations) -->
<NavigationStack {store}>
  {#snippet renderScreen(screen, index)}
    {#if screen.type === 'step1'}
      <Step1 {store} />
    {:else if screen.type === 'step2'}
      <Step2 {store} />
    {/if}
  {/snippet}
</NavigationStack>

<!-- With push/pop animations -->
<AnimatedNavigationStack {store}>
  {#snippet renderScreen(screen, index)}
    <!-- same -->
  {/snippet}
</AnimatedNavigationStack>

DESTINATION REDUCERS

Route actions to the correct child reducer based on destination type.

import { createDestinationReducer, createDestination, isDestinationType, extractDestinationState } from '@composable-svelte/core/navigation';

// Create a reducer that routes to the correct child
const destinationReducer = createDestinationReducer({
  addItem: addItemReducer,
  editItem: editItemReducer,
  confirmDelete: confirmDeleteReducer
});

// Shorthand for creating destination + reducer + types together
const { reducer, types } = createDestination({
  addItem: addItemReducer,
  editItem: editItemReducer
});

// Type guards
if (isDestinationType(state.destination, 'addItem')) {
  // state.destination is narrowed to { type: 'addItem'; state: AddItemState }
}

const addState = extractDestinationState(state.destination, 'addItem');
// addState: AddItemState | null

MATCHERS

Pattern matching for nested presentation actions.

import { matchPresentationAction, isActionAtPath, matchPaths, extractDestinationOnAction } from '@composable-svelte/core/navigation';

// Check if action matches a case path
if (isActionAtPath(action, 'addItem.saveButtonTapped')) {
  // Action is addItem's saveButtonTapped
}

// Match and extract
const result = matchPresentationAction(action, state, 'editItem.saveButtonTapped');
if (result) {
  // result is the EditItemState
}

// Multi-case matching
const matched = matchPaths(action, state, {
  'addItem.saveButtonTapped': (addState) => ({ type: 'add', item: addState }),
  'editItem.saveButtonTapped': (editState) => ({ type: 'edit', item: editState })
});

// Extract destination state when a specific action fires
const destState = extractDestinationOnAction(action, state, 'confirmDelete.confirmButtonTapped');

DISMISS DEPENDENCY

Children can dismiss themselves via an injectable dependency. Use for simple close/cancel; prefer parent observation when the parent needs to react.

import { createDismissDependency, createDismissDependencyWithCleanup, dismissDependency } from '@composable-svelte/core/navigation';

// Create dismiss function for a child
const dismiss = createDismissDependency(parentStore, 'destination');

// With cleanup callback
const dismiss = createDismissDependencyWithCleanup(parentStore, 'destination', () => {
  console.log('child dismissed');
});

// Shorthand helper
const deps = { dismiss: dismissDependency(parentStore, 'destination') };

// Child reducer uses it
case 'closeButtonTapped':
  deps.dismiss();  // OK for simple close
  return [state, Effect.none()];

ELEMENT SCOPING

Scope a store to a specific element in a list (for forEach/forEachElement patterns).

import { scopeToElement } from '@composable-svelte/core/navigation';

// Create a scoped store for a specific list item
const itemStore = scopeToElement(parentStore, {
  getArray: (s) => s.items,
  id: item.id,
  actionWrapper: (id, action) => ({ type: 'item', id, action })
});

PHASE 3 DSL

Fluent APIs for reducer composition and store scoping.

import { integrate, scopeTo } from '@composable-svelte/core/navigation';

// Fluent reducer integration
const appReducer = integrate(baseReducer)
  .with('counter', counterReducer)
  .with('todos', todoReducer)
  .build();

// Fluent store scoping for components
const childStore = scopeTo(store).into('destination').case('addItem');

ALL NAVIGATION COMPONENTS

| Component | Purpose | Import from | |-----------|---------|-------------| | Modal | Full-screen overlay dialog | core/navigation-components | | Sheet | Bottom drawer (mobile-first) | core/navigation-components | | Drawer | Side panel (left/right) | core/navigation-components | | Alert | Confirmation dialog | core/navigation-components | | Popover | Contextual popup | core/navigation-components | | Sidebar | Persistent side navigation | core/navigation-components | | Tabs | Horizontal tabbed navigation | core/navigation-components | | NavigationStack | Multi-screen stack | core/navigation-components | | AnimatedNavigationStack | Stack with push/pop animations | core/navigation-components | | DestinationRouter | Declarative destination routing | core/navigation-components |

Each component also has a *Primitive variant for advanced customization (e.g., ModalPrimitive, SheetPrimitive).


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. Stack Navigation: push, pop, popToRoot, handleStackAction for linear flows
  4. ifLet Composition: For optional children (modals, sheets, drawers)
  5. Destination Reducers: createDestinationReducer, createDestination for enum routing
  6. Matchers: matchPresentationAction, isActionAtPath for pattern matching
  7. Parent Observation: React to child completion/cancellation
  8. Dismiss Dependency: createDismissDependency for simple child self-dismissal
  9. PresentationState Lifecycle: idle → presenting → presented → dismissing → idle
  10. Motion One Integration: 26 animation helpers for all lifecycle animations
  11. URL Routing: Sync browser history with state
  12. 10 Navigation Components: Modal, Sheet, Drawer, Alert, Popover, Sidebar, Tabs, NavigationStack, AnimatedNavigationStack, DestinationRouter (+ Primitive variants)

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.