xiaohongshu
xiaohongshu
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.
Full skill instructions
This skill covers state-driven navigation patterns, PresentationState lifecycle animations, and URL routing integration.
Principle: Component lifecycle animations MUST use Motion One + PresentationState. NO CSS transitions for UI interactions.
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
.button {
transition: background-color 0.2s; /* ❌ REMOVED */
}
.modal {
transition: opacity 0.3s; /* ❌ Not state-driven, not testable */
}
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.
Non-null state = presented, null = dismissed
This creates a navigation tree where each node can optionally present a child screen.
// 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;
}
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' };
When: Child may or may not be present (modal, sheet, drawer, detail view)
// 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];
}
Critical Pattern: Parent can observe child actions to react to completion, cancellation, or other child events.
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];
}
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:
null when destination is null or different typedismiss() method that dispatches dismiss actionidle → presenting → presented → dismissing → idle
↑ ↓ ↓ ↓ ↑
└────────┴───────────┴───────────┴─────────┘
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' };
// 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}
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' }
});
});
}
});
// 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>
/* ✅ 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:
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;
}
});
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);
}
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'
These components are from the shadcn-svelte component library. See composable-svelte-components skill for full reference.
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}
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}
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}
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}
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>
// 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}
// 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}
case 'destination': {
// Only handles child actions, not dismiss
const [newState, effect] = ifLetPresentation(...)(state, action, deps);
return [newState, effect];
}
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.
interface State {
showModal: boolean; // Just a boolean, no animation lifecycle
}
interface State {
content: Content | null;
presentation: PresentationState<Content>; // Full lifecycle
}
WHY: PresentationState tracks animation lifecycle (presenting → presented → dismissing), enabling state-driven animations.
.modal {
transition: opacity 0.3s;
}
$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.
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
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
DestinationState | null)// 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}
This skill covers navigation and animation patterns for Composable Svelte:
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.
xiaohongshu
technical spec
product ux expert
database patterns
Conduct multi-agent task orchestration and workflow coordination.
Initialize project with Conductor artifacts (product definition,
Expert in web animations, transitions, and motion design using Framer Motion and CSS
Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...
PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.
Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...
Display project status, active tracks, and next actions
Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.
Generate, edit, and beat-sync AI video with leading models in one workspace.
The world's fastest calendar for remote work
Transform Your Design with AI Designer by ImgCreator.ai
Revolutionizing Video Production with AI-Powered Creativity
Extend an image past the frame and let AI fill the new aspect ratio.
Discover your celebrity doppelgänger with StarByFace!
ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.
Opus.ai: Revolutionize Your Web Experience