Page Transitions
The CSS View Transitions API lets the browser animate between two DOM states with a few lines of CSS. Reepolee's optional css/transitions.css enables it for navigation, so the page title slides between pages instead of snapping. The transition uses no JavaScript, no animation framework, and no per-route configuration.
The file ships with the project but is commented out in css/app.css by default while the final layout is being refined. Uncomment that one import to turn it on; comment it back to turn it off. Pages render the same way either way.
Enabling Page Transitions
css/app.css imports the form, toast, filter, and editor styles, then keeps the transitions import disabled:
@import "./forms.css";
@import "./toasts.css";
@import "./filters.css";
@import "./markdown-editor.css";
/* disable transitions until we figure out the final layout */
/* @import "./transitions.css"; */
Enable transitions by uncommenting the transitions.css import. Keep it after the other component imports so its view-transition rules are applied as the final animation layer.
Rebuild the CSS (bun run css:build for production, or let the watcher pick it up in dev) and the next navigation animates. Disable transitions by re-commenting the import - no other changes needed.
How It Works
The view-transitions API works by capturing the page before navigation, capturing the page after, and animating between the two snapshots. Elements that share the same view-transition-name across pages are interpolated as a single moving element - they slide, fade, or scale from their old position to their new one rather than being torn down and rebuilt.
The default transitions.css declares view-transition-name: page-title on h1 and disables group-level animation so only the title keyframes control the motion:
@view-transition {
navigation: auto;
}
h1 {
view-transition-name: page-title;
}
::view-transition-group(page-title) {
animation: none;
}
@view-transition { navigation: auto } opts the page into automatic transitions on same-origin navigation. The browser captures the outgoing page, executes the navigation, captures the incoming page, and animates between the captures.
Because every page has exactly one <h1> with the same view-transition-name, the browser knows to animate that element specifically. The stylesheet moves the title by 3% with a 300ms ease-in/ease-out animation while the rest of the page remains unchanged.
Forward vs Backward Navigation
The browser also exposes the direction of navigation, so you can animate a forward navigation differently from a back-button navigation:
:root::view-transition-old(page-title) {
animation: slide-out-right 300ms ease-in forwards;
}
:root::view-transition-new(page-title) {
animation: slide-in-right 300ms ease-out forwards;
}
:root:active-view-transition-type(backward) ::view-transition-old(page-title) {
animation: slide-out-left 300ms ease-in forwards;
}
:root:active-view-transition-type(backward) ::view-transition-new(page-title) {
animation: slide-in-left 300ms ease-out forwards;
}
@keyframes slide-out-left {
from {
transform: translateX(0%);
opacity: 1;
}
to {
transform: translateX(-3%);
opacity: 0;
}
}
@keyframes slide-in-right {
from {
transform: translateX(3%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slide-out-right {
from {
transform: translateX(0%);
opacity: 1;
}
to {
transform: translateX(3%);
opacity: 0;
}
}
@keyframes slide-in-left {
from {
transform: translateX(-3%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
The :active-view-transition-type(backward) pseudo-class targets back-button navigation specifically. Without it, every transition animates the same way regardless of direction - usually feels wrong because users expect "going back" to slide content from the opposite side.
Naming More Elements
Any element with a unique view-transition-name animates separately. Useful for hero images that move between pages, navigation indicators that slide between items, or sidebar sections that should stay in place during the transition:
.nav-active-indicator {
view-transition-name: nav-indicator;
}
header > img.logo {
view-transition-name: logo;
}
aside.sidebar {
view-transition-name: sidebar;
}
The browser captures each named element independently and interpolates between its before/after positions. If an element exists on both pages, it animates; if it only exists on one, it fades in or out.
The name must be unique on a given page. Two elements with view-transition-name: page-title on the same page will fall back to no transition for both. Use the :nth-child() selector or the data-attribute pattern (view-transition-name: var(--vt-name)) for cases where you have a list of items that should all animate.
Optional Project-Specific Opt-Outs
transitions.css does not ship a page-level opt-out or reduced-motion rule. If your project needs one - for example on a print view - add a rule in your own stylesheet:
.no-transition {
view-transition-name: none;
}
Then apply the class to the page's root element:
<main class="no-transition">
<!-- This page won't pick up the title slide -->
</main>
Or opt out entirely for the user - respecting the prefers-reduced-motion media query is the right default for accessibility:
@media (prefers-reduced-motion: reduce) {
:root::view-transition-old(*),
:root::view-transition-new(*) {
animation-duration: 0.01s;
}
}
This reduces every transition to effectively instant for users who've asked for reduced motion. The animations still happen - needed for the view-transition-name system to work - but they're imperceptible.
Browser Support
The View Transitions API is supported in Chrome, Edge, and recent Safari. Firefox is rolling it out gradually. Browsers without support simply ignore the rules - the pages still navigate, just without animation.
There is no polyfill worth recommending. The fallback (instant navigation) is the same experience users had before view transitions existed; no animation is a perfectly acceptable degradation for browsers that don't support the API.