Skip to content
omaris

System

PageTransition

Page transitions — off unless you ask for one.

import { PageTransition } from 'omaris'
Learn

Examples

Presets

Five of the seventeen presets, off unless you ask. Change key and the content transitions; here the key is the step, so the buttons drive it.

Overview
<script lang="ts">	import { Button, PageTransition, SegmentedButton } from 'omaris';	import type { PageTransitionName } from 'omaris';​	let transition = $state<PageTransitionName>('shared-x');	let step = $state(0);​	const PANELS = ['Overview', 'Orders', 'Customers'];</script>​<div class="flex w-full max-w-lg flex-col gap-4">	<SegmentedButton		label="Preset"		bind:value={transition}		items={[			{ value: 'fade', label: 'fade' },			{ value: 'fade-through', label: 'fade-through' },			{ value: 'shared-x', label: 'shared-x' },			{ value: 'shared-y', label: 'shared-y' },			{ value: 'scale', label: 'scale' }		]}	/>​	<div class="h-32 overflow-hidden rounded-shape-lg border border-border">		<PageTransition key={step} {transition} class="h-full">			<div class="flex h-full items-center justify-center bg-surface-container-low">				<span class="text-title-md">{PANELS[step]}</span>			</div>		</PageTransition>	</div>​	<div class="flex gap-2">		<Button size="xs" variant="outlined" onclick={() => (step = (step + 2) % 3)}>Back</Button>		<Button size="xs" variant="outlined" onclick={() => (step = (step + 1) % 3)}>Forward</Button>	</div></div>

In a layout

In a real app the key is the pathname, so every navigation transitions and nothing else has to change: ``svelte <PageTransition key={page.url.pathname} transition="shared-x"> {@render children()} </PageTransition> ` direction` is what makes a back button feel like going back.

/orders
<script lang="ts">	import { Button, PageTransition } from 'omaris';​	let index = $state(0);	let direction = $state<'forward' | 'backward'>('forward');​	const PAGES = ['/orders', '/orders/4021', '/orders/4021/items'];​	function go(step: number) {		direction = step > 0 ? 'forward' : 'backward';		index = Math.min(PAGES.length - 1, Math.max(0, index + step));	}</script>​<div class="flex w-full max-w-lg flex-col gap-4">	<div class="h-28 overflow-hidden rounded-shape-lg border border-border">		<PageTransition key={PAGES[index]} transition="shared-x" {direction} class="h-full">			<div class="flex h-full items-center justify-center bg-surface-container-low">				<code class="text-body-md">{PAGES[index]}</code>			</div>		</PageTransition>	</div>​	<div class="flex gap-2">		<Button size="xs" variant="outlined" disabled={index === 0} onclick={() => go(-1)}>Back</Button>		<Button size="xs" variant="outlined" disabled={index === 2} onclick={() => go(1)}>			Deeper		</Button>	</div></div>

Native stack

push is the phone's navigation stack: the arriving page slides in over the old one, which parallaxes a third of the way and dims behind it. cover rises over a page that stays put, and reveal slides the top one away.

/orders
<script lang="ts">	import { Button, PageTransition, SegmentedButton } from 'omaris';	import type { PageTransitionName } from 'omaris';​	let transition = $state<PageTransitionName>('push');	let index = $state(0);​	const PAGES = ['/orders', '/orders/4021', '/orders/4021/items'];​	function go(step: number) {		index = Math.min(PAGES.length - 1, Math.max(0, index + step));	}</script>​<div class="flex w-full max-w-lg flex-col gap-4">	<SegmentedButton		label="Pattern"		bind:value={transition}		items={[			{ value: 'push', label: 'push' },			{ value: 'cover', label: 'cover' },			{ value: 'reveal', label: 'reveal' },			{ value: 'swap', label: 'swap' }		]}	/>​	<div class="h-28 overflow-hidden rounded-shape-lg border border-border">		<PageTransition key={PAGES[index]} {transition} direction="auto" class="h-full">			<div class="flex h-full items-center justify-center bg-surface-container-low">				<code class="text-body-md">{PAGES[index]}</code>			</div>		</PageTransition>	</div>​	<div class="flex gap-2">		<Button size="xs" variant="outlined" disabled={index === 0} onclick={() => go(-1)}>Back</Button>		<Button size="xs" variant="outlined" disabled={index === 2} onclick={() => go(1)}>			Deeper		</Button>	</div></div>

Auto direction

direction="auto" keeps the keys it has seen as a stack: a key it has never seen plays forward, and one already on the stack plays backward and unwinds it. Click across the tabs, then back to one you have been to.

Overview
Last transition played: —
<script lang="ts">	import { PageTransition, SegmentedButton, Text } from 'omaris';​	const TABS = ['Overview', 'Orders', 'Menu', 'Staff'];​	let at = $state('Overview');	let played = $state('—');</script>​<div class="flex w-full max-w-lg flex-col gap-4">	<SegmentedButton		label="Section"		bind:value={at}		items={TABS.map((tab) => ({ value: tab, label: tab }))}	/>​	<div class="h-28 overflow-hidden rounded-shape-lg border border-border">		<PageTransition			key={at}			transition="shared-x"			direction="auto"			class="h-full"			onstart={(way) => (played = way)}		>			<div class="flex h-full items-center justify-center bg-surface-container-low">				<span class="text-title-md">{at}</span>			</div>		</PageTransition>	</div>​	<Text variant="label-sm" tone="muted">Last transition played: {played}</Text></div>

When to use it

Use it for

  • Route changes that should feel like an app. In +layout.svelte, set key={page.url.pathname} and transition="shared-x". A wizard or a settings screen keys on the step instead of the URL.
  • A stack like a phone's. transition="push" with direction="auto" slides a deeper page in over the old one and plays the reverse on the way back.
  • Going back that reads as going back. direction="auto" keeps the keys it has seen as a stack, so a Back button, a breadcrumb and a swipe back all play backward with no wiring.
  • Pages of different heights. The leaving page is taken out of flow and the container clips, so the document never grows a scrollbar.
  • A keyboard that should follow. focus moves focus to the arriving page; scroll="top" resets the scroll like a native screen.

Not for

  • An app that did not ask for it → leave transition={false}, the default, which renders the children with no wrapper and no cost.
  • A section that opens and closes → Accordion, or a Svelte transition: on the block.
  • Covering a slow load → Skeleton or Progress. A transition does not hide waiting.

Do

  • Pick one preset and keep it. shared-x for siblings, push for a stack, shared-y, zoom or scale for parent to child, fade-through for unrelated destinations, fade or dissolve for the least motion.
  • Leave duration alone unless you disagree with the preset. push is longer than fade because it travels further.
  • Prefer direction="auto". Set it by hand only where the keys are not a stack, like a tab strip.
  • Key on the pathname, not the whole URL, so a query-string change does not replay the page.
  • Trust the presets under RTL. shared-x, slide, push and swap read the writing direction, so forward is leftward in Arabic.

Don't

  • Put one in every layout. Use one, at the outermost layout that changes.
  • Wrap a TopAppBar or a NavigationDrawer in it. Only the page moves.
  • Key on an object. A new object every render is a transition every render.
  • Use it for a change inside a page, like a filter or a sort.
  • Use flip twice. It draws attention to itself.

API

PageTransition

Page transitions — off unless you ask for one.

transition defaults to false, and a false transition renders the children with no wrapper, no keyed block and no cost: navigation behaves exactly as it did before this component existed. Name a pattern and the page starts moving, on the same MD3 curves the components use.

— where page is SvelteKit's $app/state, so every navigation is a new key and every key change is a transition.

`direction="auto"` works out which way you went. It keeps the keys it has seen as a stack: a key it has never seen is forward and goes on the stack, and a key already on it is a Back — the stack unwinds to it and the pattern plays in reverse. That is a browser Back button, a swipe back and a breadcrumb, all without the layout being told which.

Seventeen patterns, each with the duration it actually wants, so duration is only there for when you disagree. Patterns that travel along the inline axis follow the writing direction, so forward is leftward in Arabic without a second preset.

The two pages are stacked in one grid cell while they cross, so nothing jumps and the scroll position doesn't lurch. The leaving page is made inert on its way out, so a click can't land on a page that's gone.

Neither page may move the document while they cross. Two things would, and both are handled here rather than left to the consumer:

- The pair's height. Stacked in one grid cell, the row is as tall as the taller of the two, so a tall page leaving a short one holds the document at the old height and then drops it — a scrollbar that appears for a few frames and goes. The leaving page is taken out of flow at its own size the moment it starts to leave, so the container is only ever the height of the page arriving. - The travel. A page translated 32px or a full width is still part of the scrollable overflow, so the axis it moves along grows a scrollbar mid-flight. The container clips while a transition is running and stops clipping the moment it ends, so a menu or a sticky header that overflows on purpose is unaffected the rest of the time.

import { PageTransition } from 'omaris'
<PageTransition key={page.url.pathname} transition="push" direction="auto">  {@render children()}</PageTransition>

Props

key
unknown

Change this to run a transition — page.url.pathname, usually.

transition

Defaults to false

PageTransitionName | false

The pattern, or false (the default) for none at all.

direction

Defaults to 'forward'

PageTransitionDirection | 'auto'

Which way the axis patterns travel. auto reads it off the keys it has seen: a new one is forward, one it has been to before is a Back.

duration
number

Total time for the pair, in ms. Defaults to what the pattern wants.

clip

Defaults to true

boolean

Clip the travel while a transition runs. On unless a page has something that has to escape the container mid-flight.

scroll

Defaults to 'keep'

'keep' | 'top'

Put the scroll back to the top as the new page arrives. 'top' scrolls the nearest scrollable ancestor, which is the app frame in a dashboard and the window on an ordinary page.

focus

Defaults to false

boolean

Move focus to the arriving page, the way a native screen change does, so a keyboard lands on the new page rather than back at the top of the document.

onstart
(direction: PageTransitionDirection) => void

Called as a pair starts, with the direction it is playing.

onend
() => void

Called once the pair has landed.

children required
Snippet

The page.

class
string