Curtain Transition
Between two real pages, an ink panel rises with the name of the next page, then lifts off onto it. Back plays it in reverse. Curved edge or from the top. Chrome, Edge, Safari.
View codeBetween two real pages, the one you leave fades out drifting up while the next slides in from just below, and the header stays put. CSS only, no script. Chrome, Edge, Safari; Firefox loads plainly for now.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Soft Swap to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<header class="kodza-soft-swap">
<!-- Your site header goes here (or add kodza-soft-swap to the one you have): it stays put while the page swaps under it -->
</header>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Soft Swap
A CSS-only page transition between two real pages (cross-document View Transitions). On a
click the page you leave fades out, drifting up a few pixels, while the next one slides in
from just below; the header marked kodza-soft-swap stays where it is. No script: paste it
and every link on the site swaps. Back (a "back" view transition type, from Swup, Barba or
your own startViewTransition) drifts the other way. Without the API (Firefox) or with
reduced motion, navigation stays plain. Tuned with the --kz-soft-swap-* variables, on html.
Another Kodza page transition running the page (kodza-page-engine) takes over. */
@view-transition {
navigation: auto;
}
/* Settings: on html, since both pages are drawn there (zero weight, any rule of yours wins) */
:where(html:has(.kodza-soft-swap)) {
--kz-soft-swap-leave: 0.2s; /* the old page fades out first */
--kz-soft-swap-duration: 0.46s; /* then the new one, from below to in place */
--kz-soft-swap-overlap: 0s; /* how long both are on screen together (0: never two titles at once) */
--kz-soft-swap-distance: 14px; /* how far below it starts */
--kz-soft-swap-ease: cubic-bezier(0.22, 1, 0.36, 1);
--kz-soft-swap-backdrop: transparent; /* between the two: your page colour shows through */
--kz-soft-swap-way: 1; /* 1 forward, -1 back */
}
html:has(.kodza-soft-swap):not(.kodza-page-engine):active-view-transition-type(back) {
--kz-soft-swap-way: -1;
}
html:has(.kodza-soft-swap):not(.kodza-page-engine)::view-transition {
background: var(--kz-soft-swap-backdrop, transparent);
}
html:has(.kodza-soft-swap):not(.kodza-page-engine)::view-transition-group(root) {
animation: none;
}
/* One after the other, not a crossfade: the page you leave is almost gone when the next one
starts, so two sets of titles never sit on top of each other */
html:has(.kodza-soft-swap):not(.kodza-page-engine)::view-transition-old(root) {
z-index: 1;
mix-blend-mode: normal;
animation: kodza-soft-swap-leave var(--kz-soft-swap-leave, 0.2s) cubic-bezier(0.4, 0, 1, 1) both;
}
html:has(.kodza-soft-swap):not(.kodza-page-engine)::view-transition-new(root) {
mix-blend-mode: normal;
animation: kodza-soft-swap-arrive var(--kz-soft-swap-duration, 0.46s) var(--kz-soft-swap-ease, cubic-bezier(0.22, 1, 0.36, 1)) both;
animation-delay: calc(var(--kz-soft-swap-leave, 0.2s) - var(--kz-soft-swap-overlap, 0s));
}
@keyframes kodza-soft-swap-leave {
to {
opacity: 0;
transform: translateY(calc(var(--kz-soft-swap-distance, 14px) * var(--kz-soft-swap-way, 1) * -0.5));
}
}
@keyframes kodza-soft-swap-arrive {
from {
opacity: 0;
transform: translateY(calc(var(--kz-soft-swap-distance, 14px) * var(--kz-soft-swap-way, 1)));
}
}
/* The header stays: captured apart on both pages, the new one shown from the start. One per page:
two elements with the same transition name and the browser skips the transition */
html:has(.kodza-soft-swap):not(.kodza-page-engine) .kodza-soft-swap {
view-transition-name: kodza-soft-swap;
}
html:has(.kodza-soft-swap):not(.kodza-page-engine)::view-transition-group(kodza-soft-swap) {
animation: none;
}
html:has(.kodza-soft-swap):not(.kodza-page-engine)::view-transition-old(kodza-soft-swap) {
animation: none;
opacity: 0;
}
/* It fills its group: Firefox keeps the proportions of its first capture, so a header that
changes width with the page would grow taller with it */
html:has(.kodza-soft-swap):not(.kodza-page-engine)::view-transition-new(kodza-soft-swap) {
height: 100%;
mix-blend-mode: normal;
animation: none;
}
/* Reduced motion: plain navigation, the page straight away */
@media (prefers-reduced-motion: reduce) {
@view-transition {
navigation: none;
}
html:has(.kodza-soft-swap):not(.kodza-page-engine)::view-transition-old(root),
html:has(.kodza-soft-swap):not(.kodza-page-engine)::view-transition-new(root) {
animation: none;
}
}
/* kodza-guard b917882abb3e — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
'use client';
// Kodza — Soft Swap, for React. From https://kodza.dev/components/soft-swap/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: soft-swap.css, the component's CSS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
import { useRef, type ComponentPropsWithoutRef, type ComponentRef } from 'react';
import './soft-swap.css';
export type SoftSwapProps = Omit<ComponentPropsWithoutRef<'header'>, "children"> & {
className?: string;
};
export function SoftSwap({ className, style, ...rest }: SoftSwapProps) {
const ref = useRef<ComponentRef<'header'>>(null);
const classes = ["kodza-soft-swap", className].filter(Boolean).join(' ');
return (
<header {...rest} className={classes} style={style} ref={ref} />
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.