Motion
Transitions
Shared element, FLIP reorder, spring height, sliding tab indicator, stacked toasts, drag-to-dismiss sheet and filter exits: named, numbered, with exact values.
A transition is the motion between two states of the same screen. Each demo below wraps real package components (Card, Tabs, Chip, Button) and animates around them; none of them is forked. Every demo is wrapped in MotionStage: it plays only in view, stops when you touch it, and shows the final state under prefers-reduced-motion.
How to ask for one
Say the name, or the number. Use #360 for the plan cards and Use a shared element transition from the plan list to its detail resolve to the same reference, with the exact values.
Shared element
A shared element transition (also called hero or container transform): the plan cover in the list and in the detail view carry the same view-transition-name, so the browser morphs its position and size in 420ms. Browsers without the View Transitions API get a 240ms crossfade. Use it to open an item from a list without a jump cut. Ask: "Use #360" or "a shared element transition".
Reference #360 · Pick in the gallery
Code
<script lang="ts">
import { Button, Card, MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import { flushSync } from 'svelte';
const plans = [
{ id: 'atlas', name: 'Atlas Pro', price: 'R$ 129/mo', note: '12 seats, audit log, SSO', hue: 55 },
{ id: 'orbit', name: 'Orbit Team', price: 'R$ 59/mo', note: '5 seats, shared inbox', hue: 245 },
];
// The open plan id, or null for the list. Reduced motion shows the detail (the final state).
let open = $state<string | null>(null);
// The stage context, captured from the snippet so the script can react to the stage clock.
let stage = $state<MotionCtx>();
let lastCycle = -1;
type Morph = Document & { startViewTransition?: (cb: () => void) => unknown };
// View Transitions API: the cover in the list and the cover in the detail share one
// view-transition-name, so the browser morphs position and size between the two states.
// Without the API the swap is a plain crossfade (CSS keyframes on the incoming view).
function go(next: string | null, animate: boolean) {
const doc = document as Morph;
if (animate && doc.startViewTransition) doc.startViewTransition(() => flushSync(() => (open = next)));
else open = next;
}
$effect(() => {
if (!stage) return;
if (stage.reduced) {
open = 'atlas';
return;
}
if (!stage.playing || stage.cycle === lastCycle) return;
lastCycle = stage.cycle;
go(open === null ? 'atlas' : null, true);
});
</script>
<MotionStage label="Shared element: a plan cover grows into the detail view" loop={2800}>
{#snippet children(m)}
{@const detail = plans.find((p) => p.id === open)}
<div class="se" {@attach () => { stage = m; }}>
{#if detail}
<div class="se__view">
<Card>
<div class="se__detail">
<span class="se__cover se__cover--shared se__cover--big" style="--h:{detail.hue}"></span>
<div class="se__copy">
<p class="se__title">{detail.name}</p>
<p class="se__note">{detail.note}</p>
<p class="se__price">{detail.price}</p>
<Button size="sm" variant="outlined" onclick={() => go(null, !m.reduced)}>Back to plans</Button>
</div>
</div>
</Card>
</div>
{:else}
<ul class="se__view se__list">
{#each plans as p (p.id)}
<li>
<button type="button" class="se__tile" aria-label={`Open ${p.name}`} onclick={() => go(p.id, !m.reduced)}>
<span class="se__cover" class:se__cover--shared={p.id === 'atlas'} style="--h:{p.hue}"></span>
<span class="se__name">{p.name}</span>
<span class="se__price">{p.price}</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/snippet}
</MotionStage>
<style>
.se { width: 100%; max-width: 360px; }
.se__view { animation: se-fade 240ms cubic-bezier(0.2, 0, 0, 1) both; }
.se__list { display: grid; grid-template-columns: 1fr 1fr; gap: var(--ca-space-3); margin: 0; padding: 0; list-style: none; }
.se__tile {
display: grid; gap: var(--ca-space-1); inline-size: 100%; text-align: start; cursor: pointer;
min-block-size: var(--ca-touch-target); padding: var(--ca-space-2); font: inherit; color: var(--ca-text-primary);
background: var(--ca-surface-raised); border: var(--ca-border-width) solid var(--ca-outline-subtle);
border-radius: var(--ca-radius-lg);
}
.se__tile:focus-visible { outline: var(--ca-border-width-focus) solid var(--ca-focus-ring); outline-offset: var(--ca-border-width-focus); }
.se__cover {
display: block; aspect-ratio: 4 / 3; border-radius: var(--ca-radius-md);
background: linear-gradient(135deg, oklch(0.72 0.15 var(--h)), oklch(0.5 0.14 calc(var(--h) + 30)));
}
.se__cover--shared { view-transition-name: se-cover; }
.se__cover--big { inline-size: 7.5rem; flex: none; aspect-ratio: 1; }
.se__name { font-weight: var(--ca-font-weight-semibold); }
.se__price { margin: 0; color: var(--ca-text-secondary); font-size: var(--ca-font-size-body2); }
.se__detail { display: flex; gap: var(--ca-space-4); align-items: flex-start; }
.se__copy { display: grid; gap: var(--ca-space-1); justify-items: start; }
.se__title { margin: 0; font-weight: var(--ca-font-weight-semibold); font-size: var(--ca-font-size-h6); }
.se__note { margin: 0; color: var(--ca-text-secondary); font-size: var(--ca-font-size-body2); }
@keyframes se-fade { from { opacity: 0; } to { opacity: 1; } }
:global(::view-transition-group(se-cover)) {
animation-duration: 420ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
@media (prefers-reduced-motion: reduce) { .se__view { animation: none; } }
</style>FLIP reorder
A FLIP reorder (First, Last, Invert, Play, also called a layout animation): measure each row, let the DOM reorder, translate each row back to where it was, then release it over 420ms. Use it when a sort or filter changes the order and the user must follow which row went where. Ask: "Use #361" or "a FLIP reorder".
- Hooli R$ 12.480
- Globex R$ 9.310
- Umbrella R$ 6.740
- Northwind R$ 4.820
- Initech R$ 2.150
Reference #361 · Pick in the gallery
Code
<script lang="ts">
import { Button, MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import { tick } from 'svelte';
type Row = { id: string; name: string; mrr: number; churn: number };
const rows: Row[] = [
{ id: 'a', name: 'Northwind', mrr: 4820, churn: 1.2 },
{ id: 'b', name: 'Globex', mrr: 9310, churn: 3.4 },
{ id: 'c', name: 'Initech', mrr: 2150, churn: 0.8 },
{ id: 'd', name: 'Umbrella', mrr: 6740, churn: 2.1 },
{ id: 'e', name: 'Hooli', mrr: 12480, churn: 4.6 },
];
const sorts = {
mrr: { label: 'MRR', cmp: (x: Row, y: Row) => y.mrr - x.mrr },
churn: { label: 'Churn', cmp: (x: Row, y: Row) => x.churn - y.churn },
} as const;
type SortKey = keyof typeof sorts;
let sort = $state<SortKey>('mrr');
let list = $state<HTMLElement>();
let stage = $state<MotionCtx>();
let lastCycle = -1;
const ordered = $derived([...rows].sort(sorts[sort].cmp));
// FLIP: First (measure every row), Last (let Svelte reorder the DOM), Invert (translate each row
// back to where it was), Play (transition the translate to zero).
async function reorder(next: SortKey) {
if (next === sort) return;
const root = list;
const reduced = stage?.reduced ?? false;
const first = new Map<string, number>();
if (root && !reduced) for (const el of root.querySelectorAll<HTMLElement>('[data-id]')) first.set(el.dataset.id!, el.getBoundingClientRect().top);
sort = next;
await tick();
if (!root || reduced) return;
for (const el of root.querySelectorAll<HTMLElement>('[data-id]')) {
const dy = (first.get(el.dataset.id!) ?? 0) - el.getBoundingClientRect().top;
if (!dy) continue;
el.animate(
[{ transform: `translateY(${dy}px)` }, { transform: 'translateY(0)' }],
{ duration: 420, easing: 'cubic-bezier(0.2, 0, 0, 1)' }
);
}
}
$effect(() => {
if (!stage) return;
if (stage.reduced) {
sort = 'churn';
return;
}
if (!stage.playing || stage.cycle === lastCycle) return;
lastCycle = stage.cycle;
void reorder(sort === 'mrr' ? 'churn' : 'mrr');
});
</script>
<MotionStage label="FLIP reorder: rows slide to their new place when the sort changes" loop={2400}>
{#snippet children(m)}
<div class="fl" {@attach () => { stage = m; }}>
<div class="fl__bar" role="group" aria-label="Sort customers by">
{#each Object.entries(sorts) as [key, s] (key)}
<Button size="sm" variant={sort === key ? 'filled' : 'outlined'} aria-pressed={sort === key} onclick={() => reorder(key as SortKey)}>{s.label}</Button>
{/each}
</div>
<ul class="fl__list" bind:this={list}>
{#each ordered as r (r.id)}
<li class="fl__row" data-id={r.id}>
<span class="fl__name">{r.name}</span>
<span class="fl__num">{sort === 'mrr' ? `R$ ${r.mrr.toLocaleString('pt-BR')}` : `${r.churn.toFixed(1)}% churn`}</span>
</li>
{/each}
</ul>
</div>
{/snippet}
</MotionStage>
<style>
.fl { width: 100%; max-width: 340px; display: grid; gap: var(--ca-space-3); }
.fl__bar { display: flex; gap: var(--ca-space-2); }
.fl__list { display: grid; gap: var(--ca-space-2); margin: 0; padding: 0; list-style: none; }
.fl__row {
display: flex; justify-content: space-between; gap: var(--ca-space-3);
padding: var(--ca-space-2-5) var(--ca-space-3);
background: var(--ca-surface-raised); border: var(--ca-border-width) solid var(--ca-outline-subtle);
border-radius: var(--ca-radius-md);
}
.fl__name { font-weight: var(--ca-font-weight-semibold); }
.fl__num { color: var(--ca-text-secondary); font-variant-numeric: tabular-nums; }
</style>Height spring
A height spring: the panel's height follows a Spring (stiffness 0.18, damping 0.72), so it overshoots slightly and settles, instead of a stiff CSS ease. It measures the content, so any text length works. Use it for FAQs, expandable rows and disclosure panels. Ask: "Use #362" or "a height spring".
On the day you subscribed, every month. Upgrades are prorated to the day, downgrades apply at the next renewal.
Yes. A new seat costs R$ 19, prorated for the days left. Remove one and the credit lands on the next invoice.
Settings, Data, Export. You get a CSV per table within a minute, and a link by email for large workspaces.
Reference #362 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import { ChevronDown } from '@lucide/svelte';
import { Spring } from 'svelte/motion';
const faqs = [
{ id: 'billing', q: 'When am I billed?', a: 'On the day you subscribed, every month. Upgrades are prorated to the day, downgrades apply at the next renewal.' },
{ id: 'seats', q: 'Can I add seats mid-cycle?', a: 'Yes. A new seat costs R$ 19, prorated for the days left. Remove one and the credit lands on the next invoice.' },
{ id: 'export', q: 'How do I export my data?', a: 'Settings, Data, Export. You get a CSV per table within a minute, and a link by email for large workspaces.' },
];
let opened = $state<string | null>('billing');
let stage = $state<MotionCtx>();
let lastCycle = -1;
let heights = $state<Record<string, number>>({});
// One spring per panel drives its height in px. Closing aims at 0, opening at the measured height.
const springs = Object.fromEntries(faqs.map((f) => [f.id, new Spring(0, { stiffness: 0.18, damping: 0.72 })]));
function measure(id: string, node: HTMLElement) {
const ro = new ResizeObserver(() => (heights[id] = node.scrollHeight));
ro.observe(node);
return () => ro.disconnect();
}
$effect(() => {
const reduced = stage?.reduced ?? false;
for (const f of faqs) {
const target = opened === f.id ? (heights[f.id] ?? 0) : 0;
if (reduced) springs[f.id].set(target, { instant: true });
else springs[f.id].target = target;
}
});
$effect(() => {
if (!stage) return;
if (stage.reduced) {
opened = 'billing';
return;
}
if (!stage.playing || stage.cycle === lastCycle) return;
lastCycle = stage.cycle;
const i = faqs.findIndex((f) => f.id === opened);
opened = faqs[(i + 1) % faqs.length].id;
});
</script>
<MotionStage label="Height spring: accordion panels open and close with a spring" loop={2200}>
{#snippet children(m)}
<div class="hs" {@attach () => { stage = m; }}>
{#each faqs as f (f.id)}
{@const isOpen = opened === f.id}
<div class="hs__item" data-open={isOpen}>
<h3 class="hs__h">
<button type="button" class="hs__trigger" aria-expanded={isOpen} aria-controls={`hs-${f.id}`} onclick={() => (opened = isOpen ? null : f.id)}>
<span>{f.q}</span>
<ChevronDown size={16} class="hs__chev" aria-hidden="true" />
</button>
</h3>
<div class="hs__clip" id={`hs-${f.id}`} role="region" aria-label={f.q} inert={!isOpen} style="--h:{Math.max(0, springs[f.id].current)}px">
<p class="hs__body" {@attach (n) => measure(f.id, n)}>{f.a}</p>
</div>
</div>
{/each}
</div>
{/snippet}
</MotionStage>
<style>
.hs { width: 100%; max-width: 380px; display: grid; gap: var(--ca-space-2); }
.hs__item {
background: var(--ca-surface-raised); border: var(--ca-border-width) solid var(--ca-outline-subtle);
border-radius: var(--ca-radius-lg); overflow: hidden;
}
.hs__h { margin: 0; font: inherit; }
.hs__trigger {
display: flex; inline-size: 100%; align-items: center; justify-content: space-between; gap: var(--ca-space-3);
min-block-size: var(--ca-touch-target); padding: var(--ca-space-3) var(--ca-space-4); font: inherit; font-weight: var(--ca-font-weight-semibold);
color: var(--ca-text-primary); background: transparent; border: 0; cursor: pointer; text-align: start;
}
.hs__trigger:focus-visible { outline: var(--ca-border-width-focus) solid var(--ca-focus-ring); outline-offset: calc(-1 * var(--ca-border-width-focus)); }
.hs__item[data-open='true'] :global(.hs__chev) { transform: rotate(180deg); }
.hs__trigger :global(.hs__chev) { flex: none; transition: transform 240ms cubic-bezier(0.2, 0, 0, 1); }
.hs__clip { block-size: var(--h); overflow: hidden; }
.hs__body { margin: 0; padding: 0 var(--ca-space-4) var(--ca-space-4); color: var(--ca-text-secondary); font-size: var(--ca-font-size-body2); }
</style>Tab indicator
A sliding tab indicator (a magic line, or a pill): one element that translates and stretches under the active tab in 360ms, wrapped around the untouched package Tabs. Use it for tab bars and segmented controls so the eye follows the selection. Ask: "Use #363" or "a sliding tab indicator".
2,481 active members, up 6.2% on last week.
Next invoice R$ 4.820 on 28 Oct, paid by card.
3 devices signed in, two-factor is on.
Reference #363 · Pick in the gallery
Code
<script lang="ts">
import { MotionStage, Tabs, TabsContent, TabsList, TabsTrigger, type MotionCtx } from '@codeatlasdev/ui';
const tabs = [
{ value: 'overview', label: 'Overview', body: '2,481 active members, up 6.2% on last week.' },
{ value: 'billing', label: 'Billing', body: 'Next invoice R$ 4.820 on 28 Oct, paid by card.' },
{ value: 'security', label: 'Security', body: '3 devices signed in, two-factor is on.' },
];
let value = $state('overview');
let stage = $state<MotionCtx>();
let rail = $state<HTMLElement>();
let lastCycle = -1;
// Geometry of the active trigger, in px relative to the list. The pill is one element that
// translates and scales; it never re-renders per tab.
let x = $state(0);
let w = $state(0);
let ready = $state(false);
function place() {
const el = rail?.querySelector<HTMLElement>('.ca-tabs-trigger[data-state="active"]');
if (!rail || !el) return;
x = el.offsetLeft;
w = el.offsetWidth;
ready = true;
}
$effect(() => {
value;
if (!rail) return;
place();
const ro = new ResizeObserver(place);
ro.observe(rail);
return () => ro.disconnect();
});
$effect(() => {
if (!stage) return;
if (stage.reduced) {
value = 'billing';
return;
}
if (!stage.playing || stage.cycle === lastCycle) return;
lastCycle = stage.cycle;
value = tabs[(tabs.findIndex((t) => t.value === value) + 1) % tabs.length].value;
});
</script>
<MotionStage label="Tab indicator: a pill slides under the active tab" loop={1800}>
{#snippet children(m)}
<div class="ti" class:ti--ready={ready} class:ti--instant={m.reduced} {@attach () => { stage = m; }}>
<Tabs bind:value>
<div class="ti__rail" bind:this={rail} style="--ti-x:{x}px; --ti-w:{w}px">
<TabsList>
<span class="ti__pill" aria-hidden="true"></span>
{#each tabs as t (t.value)}
<TabsTrigger value={t.value}>{t.label}</TabsTrigger>
{/each}
</TabsList>
</div>
{#each tabs as t (t.value)}
<TabsContent value={t.value}><p class="ti__body">{t.body}</p></TabsContent>
{/each}
</Tabs>
</div>
{/snippet}
</MotionStage>
<style>
.ti { width: 100%; max-width: 380px; }
.ti__rail { display: inline-flex; max-inline-size: 100%; }
/* The package trigger paints its own active pill; hand that job to the sliding one. */
.ti--ready :global(.ca-tabs-trigger[data-state='active']) { background: transparent; }
.ti :global(.ca-tabs-trigger[data-state='active']) { position: relative; z-index: 1; }
.ti :global(.ca-tabs-list) { position: relative; }
.ti__pill {
position: absolute; inset-block: var(--ca-space-1); inset-inline-start: 0; z-index: 0;
inline-size: var(--ti-w); border-radius: var(--ca-radius-pill); background: var(--ca-brand-fill);
transform: translateX(var(--ti-x)); opacity: 0;
}
.ti--ready .ti__pill { opacity: 1; transition: transform 360ms cubic-bezier(0.2, 0, 0, 1), inline-size 360ms cubic-bezier(0.2, 0, 0, 1); }
.ti--instant .ti__pill { transition: none; }
.ti__body { margin: var(--ca-space-4) 0 0; color: var(--ca-text-secondary); }
</style>Stacked toasts
Stacked toasts: each new one enters from above in 320ms, the older ones sit lower, smaller and dimmer so the stack reads as depth, and a dismissed one slides aside in 240ms. At most three are kept. Use it for bursts of in-app notifications. Ask: "Use #364" or "a toast stack".
Reference #364 · Pick in the gallery
Code
<script lang="ts">
import { Button, MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import { CheckCircle, Info, TriangleAlert, X } from '@lucide/svelte';
import { cubicOut } from 'svelte/easing';
type Kind = 'success' | 'info' | 'warning';
type Toast = { id: number; kind: Kind; title: string; body: string };
const script: Omit<Toast, 'id'>[] = [
{ kind: 'success', title: 'Invoice paid', body: 'R$ 4.820 from Northwind' },
{ kind: 'info', title: 'New comment', body: 'Ana on "Q4 forecast"' },
{ kind: 'warning', title: 'Seat limit', body: '11 of 12 seats used' },
{ kind: 'success', title: 'Export ready', body: 'customers.csv, 2.4 MB' },
];
const icons = { success: CheckCircle, info: Info, warning: TriangleAlert };
const MAX = 3;
let toasts = $state<Toast[]>([]);
let n = 0;
let stage = $state<MotionCtx>();
let lastCycle = -1;
function push() {
const t = { ...script[n % script.length], id: n++ };
toasts = [t, ...toasts].slice(0, MAX);
}
// Dismiss: slide up-and-out sideways while fading; absolute positioning keeps the rest from jumping.
function leave(_: Element, { reduced }: { reduced: boolean }) {
return {
duration: reduced ? 0 : 240,
easing: cubicOut,
css: (t: number, u: number) => `opacity:${t}; translate:${u * 2.5}rem 0`,
};
}
const dismiss = (id: number) => (toasts = toasts.filter((t) => t.id !== id));
$effect(() => {
if (!stage) return;
if (stage.reduced) {
if (!toasts.length) for (let i = 0; i < MAX; i++) push();
return;
}
if (!stage.playing || stage.cycle === lastCycle) return;
lastCycle = stage.cycle;
// Two pushes, then the oldest leaves: enter, stack, dismiss.
if (toasts.length >= MAX) dismiss(toasts[toasts.length - 1].id);
else push();
});
</script>
<MotionStage label="Stacked toasts: they enter, stack up and dismiss" loop={1400}>
{#snippet children(m)}
<div class="ts" {@attach () => { stage = m; }}>
<div class="ts__bar">
<Button size="sm" onclick={push}>Show toast</Button>
</div>
<ol class="ts__stack" aria-label="Notifications">
{#each toasts as t, i (t.id)}
{@const Icon = icons[t.kind]}
<li class="ts__item" class:ts__item--instant={m.reduced} style="--i:{i}" out:leave={{ reduced: m.reduced }}>
<div class="ts__toast ts__toast--{t.kind}" class:ts__toast--behind={i > 0} aria-hidden={i > 0 ? 'true' : undefined} inert={i > 0}>
<Icon size={18} aria-hidden="true" />
<div class="ts__text">
<p class="ts__title">{t.title}</p>
<p class="ts__body">{t.body}</p>
</div>
<button type="button" class="ts__x" aria-label={`Dismiss ${t.title}`} onclick={() => dismiss(t.id)}><X size={14} aria-hidden="true" /></button>
</div>
</li>
{/each}
</ol>
</div>
{/snippet}
</MotionStage>
<style>
.ts { width: 100%; max-width: 340px; display: grid; gap: var(--ca-space-3); }
.ts__stack { position: relative; margin: 0; padding: 0; list-style: none; block-size: 9.5rem; }
/* Newest on top (i = 0). Older ones sit lower, smaller and dimmer: the stack reads as depth. */
.ts__item {
position: absolute; inset-inline: 0; inset-block-start: 0;
transform: translateY(calc(var(--i) * 0.9rem)) scale(calc(1 - var(--i) * 0.05));
transform-origin: 50% 0; opacity: calc(1 - var(--i) * 0.28); z-index: calc(10 - var(--i));
transition: transform 320ms cubic-bezier(0.2, 0, 0, 1), opacity 320ms cubic-bezier(0.2, 0, 0, 1);
animation: ts-in 320ms cubic-bezier(0.2, 0, 0, 1) both;
}
.ts__item--instant { transition: none; animation: none; }
@keyframes ts-in { from { opacity: 0; transform: translateY(-1.5rem) scale(0.96); } }
.ts__toast {
display: flex; align-items: center; gap: var(--ca-space-3); padding: var(--ca-space-3) var(--ca-space-4);
background: var(--ca-surface-raised); color: var(--ca-text-primary);
border: var(--ca-border-width) solid var(--ca-outline-subtle); border-inline-start: var(--ca-border-width-strong) solid var(--ca-brand-fill);
border-radius: var(--ca-radius-md); box-shadow: var(--ca-shadow-md);
}
/* Behind the front toast: only the surface peeks out; its text fades in when it moves to the front. */
.ts__toast > :global(*) { transition: opacity 320ms cubic-bezier(0.2, 0, 0, 1); }
.ts__toast--behind > :global(*) { opacity: 0; }
.ts__toast--success { border-inline-start-color: var(--ca-semantic-success); }
.ts__toast--warning { border-inline-start-color: var(--ca-semantic-warning); }
.ts__text { flex: 1; min-inline-size: 0; }
.ts__title { margin: 0; font-weight: var(--ca-font-weight-semibold); font-size: var(--ca-font-size-body2); }
.ts__body { margin: 0; color: var(--ca-text-secondary); font-size: var(--ca-font-size-caption); }
.ts__x {
display: grid; place-items: center; inline-size: var(--ca-space-6); block-size: var(--ca-space-6); flex: none;
color: var(--ca-text-secondary); background: transparent; border: 0; border-radius: var(--ca-radius-sm); cursor: pointer;
}
@media (pointer: coarse) { .ts__x { inline-size: var(--ca-touch-target); block-size: var(--ca-touch-target); margin-block: calc(-1 * var(--ca-space-2)); } }
.ts__x:focus-visible { outline: var(--ca-border-width-focus) solid var(--ca-focus-ring); outline-offset: var(--ca-border-width-focus); }
</style>Sheet spring
A spring sheet (bottom sheet with swipe to dismiss): it springs up with stiffness 0.2 and damping 0.75, follows your finger when dragged down, and dismisses past 30% of its height or a flick faster than 0.5 px/ms; otherwise it springs back. Use it for mobile pickers and filters. Ask: "Use #365" or "a spring sheet with drag to dismiss".
Trip to Lisbon
3 travelers, 12-19 Nov
Choose seats
- 14A WindowR$ 0
- 14B MiddleR$ 0
- 3C Aisle, extra legroomR$ 89
Reference #365 · Pick in the gallery
Code
<script lang="ts">
import { Button, MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import { Spring } from 'svelte/motion';
// The sheet's offset from its resting place, in px of the stage: 0 = open, SHEET = fully hidden.
const SHEET = 256;
const offset = new Spring(SHEET, { stiffness: 0.2, damping: 0.75 });
let open = $state(false);
let dragging = $state(false);
let stage = $state<MotionCtx>();
let lastCycle = -1;
let startY = 0;
let startOffset = 0;
let lastY = 0;
let lastT = 0;
let velocity = 0;
// The offset as dragged: the Spring only advances on the next frame, so release reads this.
let dragged = 0;
function setOpen(next: boolean) {
open = next;
if (stage?.reduced) offset.set(next ? 0 : SHEET, { instant: true });
else offset.target = next ? 0 : SHEET;
}
function down(e: PointerEvent) {
if (!e.isPrimary || !open) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragging = true;
startY = lastY = e.clientY;
startOffset = dragged = offset.current;
lastT = e.timeStamp;
velocity = 0;
}
function move(e: PointerEvent) {
if (!dragging) return;
const dt = e.timeStamp - lastT;
if (dt > 0) velocity = (e.clientY - lastY) / dt;
lastY = e.clientY;
lastT = e.timeStamp;
// Dragging up resists (rubber band), dragging down follows the finger 1:1.
const raw = startOffset + (e.clientY - startY);
dragged = raw < 0 ? raw / 4 : Math.min(SHEET, raw);
offset.set(dragged, { instant: true });
}
function up() {
if (!dragging) return;
dragging = false;
// Past 30% of the height, or a fast flick (> 0.5 px/ms), dismisses; otherwise it springs back.
setOpen(!(dragged > SHEET * 0.3 || velocity > 0.5));
}
function key(e: KeyboardEvent) {
if (e.key === 'Escape' && open) setOpen(false);
}
$effect(() => {
if (!stage) return;
if (stage.reduced) {
setOpen(true);
return;
}
if (!stage.playing || stage.cycle === lastCycle) return;
lastCycle = stage.cycle;
setOpen(!open);
});
</script>
<MotionStage label="Sheet spring: a bottom sheet springs up and drags down to dismiss" loop={2600}>
{#snippet children(m)}
<div class="sh" role="presentation" {@attach () => { stage = m; }} onkeydown={key}>
<div class="sh__page">
<p class="sh__title">Trip to Lisbon</p>
<p class="sh__sub">3 travelers, 12-19 Nov</p>
<Button onclick={() => setOpen(true)}>Choose seats</Button>
</div>
<div class="sh__scrim" style="--o:{1 - Math.min(1, Math.max(0, offset.current) / SHEET)}"></div>
<div class="sh__sheet" inert={!open} role="dialog" aria-label="Choose seats" style="--y:{offset.current}px">
<div class="sh__grab" role="presentation" onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}>
<span class="sh__handle" aria-hidden="true"></span>
<p class="sh__sheet-title">Choose seats</p>
</div>
<ul class="sh__seats">
<li><span>14A Window</span><span>R$ 0</span></li>
<li><span>14B Middle</span><span>R$ 0</span></li>
<li><span>3C Aisle, extra legroom</span><span>R$ 89</span></li>
</ul>
<Button variant="outlined" onclick={() => setOpen(false)}>Done</Button>
</div>
</div>
{/snippet}
</MotionStage>
<style>
.sh {
position: relative; overflow: hidden; inline-size: 100%; max-inline-size: 340px; block-size: calc(10rem + 256px);
background: var(--ca-surface-raised); border: var(--ca-border-width) solid var(--ca-outline-subtle); border-radius: var(--ca-radius-lg);
}
.sh__page { display: grid; gap: var(--ca-space-1); justify-items: start; align-content: start; block-size: 10rem; padding: var(--ca-space-4); }
.sh__title { margin: 0; font-weight: var(--ca-font-weight-semibold); font-size: var(--ca-font-size-h6); }
.sh__sub { margin: 0 0 var(--ca-space-2); color: var(--ca-text-secondary); font-size: var(--ca-font-size-body2); }
.sh__scrim { position: absolute; inset: 0; background: var(--ca-scrim-color); opacity: calc(var(--o) * 0.5); pointer-events: none; }
.sh__sheet {
position: absolute; inset-inline: 0; inset-block-end: 0; display: grid; gap: var(--ca-space-3); justify-items: start;
padding: 0 var(--ca-space-4) var(--ca-space-4); background: var(--ca-surface-overlay);
border-top: var(--ca-border-width) solid var(--ca-outline-subtle); border-radius: var(--ca-radius-xl) var(--ca-radius-xl) 0 0;
box-shadow: var(--ca-shadow-xl); transform: translateY(var(--y));
}
.sh__grab { inline-size: calc(100% + 2 * var(--ca-space-4)); margin-inline: calc(-1 * var(--ca-space-4)); padding: var(--ca-space-2) var(--ca-space-4) 0; touch-action: none; cursor: grab; user-select: none; }
.sh__handle { display: block; inline-size: var(--ca-space-10); block-size: var(--ca-space-1); margin: 0 auto var(--ca-space-2); border-radius: var(--ca-radius-pill); background: var(--ca-outline-strong); }
.sh__sheet-title { margin: 0; font-weight: var(--ca-font-weight-semibold); }
.sh__seats { inline-size: 100%; margin: 0; padding: 0; list-style: none; display: grid; gap: var(--ca-space-1); font-size: var(--ca-font-size-body2); }
.sh__seats li { display: flex; justify-content: space-between; padding: var(--ca-space-1) 0; border-block-end: var(--ca-border-width) solid var(--ca-outline-subtle); }
</style>Filter list
An animated filter (list exit animation): rows that no longer match shrink and fade in 240ms, survivors slide into the gap in 320ms, and the total counts to its new value in 420ms. Use it for catalogs and task lists so the user sees what disappeared. Ask: "Use #366" or "an animated filter".
6 projects, R$ 0
- Brand audit Design R$ 2.400
- API gateway Dev R$ 5.200
- Icon set Design R$ 1.100
- CI pipeline Ops R$ 1.800
- Checkout flow Dev R$ 3.900
- On-call runbook Ops R$ 900
Reference #366 · Pick in the gallery
Code
<script lang="ts">
import { Chip, MotionStage, type MotionCtx } from '@codeatlasdev/ui';
import { flip } from 'svelte/animate';
import { cubicOut } from 'svelte/easing';
import { Tween } from 'svelte/motion';
type Item = { id: string; name: string; kind: 'Design' | 'Dev' | 'Ops'; price: number };
const items: Item[] = [
{ id: '1', name: 'Brand audit', kind: 'Design', price: 2400 },
{ id: '2', name: 'API gateway', kind: 'Dev', price: 5200 },
{ id: '3', name: 'Icon set', kind: 'Design', price: 1100 },
{ id: '4', name: 'CI pipeline', kind: 'Ops', price: 1800 },
{ id: '5', name: 'Checkout flow', kind: 'Dev', price: 3900 },
{ id: '6', name: 'On-call runbook', kind: 'Ops', price: 900 },
];
const filters = ['All', 'Design', 'Dev', 'Ops'] as const;
type Filter = (typeof filters)[number];
let filter = $state<Filter>('All');
let stage = $state<MotionCtx>();
let lastCycle = -1;
const shown = $derived(filter === 'All' ? items : items.filter((i) => i.kind === filter));
const total = new Tween(0, { duration: 420, easing: cubicOut });
$effect(() => {
const sum = shown.reduce((s, i) => s + i.price, 0);
if (stage?.reduced) total.set(sum, { duration: 0 });
else total.target = sum;
});
// Exit: shrink and fade (transform/opacity only); enter is the mirror. The flip animation moves
// the survivors into the gap, so nothing jumps.
function pop(_: Element, { reduced }: { reduced: boolean }) {
return { duration: reduced ? 0 : 240, easing: cubicOut, css: (t: number) => `opacity:${t}; transform: scale(${0.92 + 0.08 * t})` };
}
$effect(() => {
if (!stage) return;
if (stage.reduced) {
filter = 'Dev';
return;
}
if (!stage.playing || stage.cycle === lastCycle) return;
lastCycle = stage.cycle;
filter = filters[(filters.indexOf(filter) + 1) % filters.length];
});
</script>
<MotionStage label="Filter list: rows exit and enter as the filter changes" loop={1800}>
{#snippet children(m)}
<div class="fi" {@attach () => { stage = m; }}>
<div class="fi__chips" role="group" aria-label="Filter projects">
{#each filters as f (f)}
<Chip label={f} selected={filter === f} onclick={() => (filter = f)} />
{/each}
</div>
<p class="fi__total" aria-live="polite">{shown.length} projects, R$ {Math.round(total.current).toLocaleString('pt-BR')}</p>
<ul class="fi__list">
{#each shown as it (it.id)}
<li class="fi__row" animate:flip={{ duration: m.reduced ? 0 : 320, easing: cubicOut }} in:pop={{ reduced: m.reduced }} out:pop={{ reduced: m.reduced }}>
<span class="fi__name">{it.name}</span>
<span class="fi__kind">{it.kind}</span>
<span class="fi__price">R$ {it.price.toLocaleString('pt-BR')}</span>
</li>
{/each}
</ul>
</div>
{/snippet}
</MotionStage>
<style>
.fi { width: 100%; max-width: 360px; display: grid; gap: var(--ca-space-3); }
.fi__chips { display: flex; flex-wrap: wrap; gap: var(--ca-space-2); }
.fi__total { margin: 0; color: var(--ca-text-secondary); font-size: var(--ca-font-size-body2); font-variant-numeric: tabular-nums; }
.fi__list { display: grid; gap: var(--ca-space-2); margin: 0; padding: 0; list-style: none; min-block-size: 15.5rem; align-content: start; }
.fi__row {
display: grid; grid-template-columns: 1fr auto auto; gap: var(--ca-space-3); align-items: center;
padding: var(--ca-space-2) var(--ca-space-3);
background: var(--ca-surface-raised); border: var(--ca-border-width) solid var(--ca-outline-subtle); border-radius: var(--ca-radius-md);
}
.fi__name { font-weight: var(--ca-font-weight-semibold); }
.fi__kind { color: var(--ca-text-secondary); font-size: var(--ca-font-size-caption); }
.fi__price { font-variant-numeric: tabular-nums; }
</style>