# Soft Swap

> Between 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.

Preloaders & transitions · page transition, view transitions, multi-page, css only, blog, swup · HTML and CSS only · no dependencies · updated 2026-10-31
Demo: https://kodza.dev/components/soft-swap/
Source on GitHub: https://github.com/kodza-dev/kodza/tree/main/components/soft-swap

Takes the whole screen (or reacts to the page scroll): give it a page, not a small box.

## Files

The code below, served as files: byte for byte what the Kodza site copies (the CSS includes its theme guard).

- [`kodza-soft-swap.css`](https://kodza.dev/registry/soft-swap/kodza-soft-swap.css) · 4.3 KB
- [`kodza-soft-swap.html`](https://kodza.dev/registry/soft-swap/kodza-soft-swap.html) · 0.2 KB · the markup, to adapt and paste into your page

**Can you run shell commands? Download the CSS, don't retype it.** Retyping costs thousands of tokens and can drop or change lines of the CSS. Put the files where your stack expects them (Use it says where: change the `-o` paths):

```bash
curl -fsSL --create-dirs -o kodza/kodza-soft-swap.css https://kodza.dev/registry/soft-swap/kodza-soft-swap.css
```

Then load the file from your page as Use it shows, instead of pasting the content. The HTML is the part you adapt (text, links, modifiers): paste it from the Code section. Copy the CSS from the Code section only if you can't run commands, exactly as it is. (Windows PowerShell: `curl.exe`.)

## API

- Root: `.kodza-soft-swap`
- Element: put the root class on the page's own paragraph or heading. It keeps the page's font, size and layout; the component only adds its effect.

## Dark mode

Nothing to set: it takes its colours from the text around it, or looks the same on a light and a dark page.

## Use it

### Plain HTML page

- CSS: `kodza/kodza-soft-swap.css` next to the page (the Files commands as they are), linked in `<head>`: `<link rel="stylesheet" href="kodza/kodza-soft-swap.css">`. Without a shell, paste it in a `<style>` in `<head>` instead. It only touches `kodza-` classes.
- HTML: where the component goes. Paste it as many times as you need: instances never interfere.
- No JS.
- Restyle with the `--kz-…` variables, set on the component's root element itself (the root declares its own defaults, so a value on an ancestor is ignored), and with the modifiers / attributes listed under API.
- Font: it takes the page's own. The Kodza look is Geist: `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">`.

### Claude artifact (claude.ai)

- Paste the CSS in a `<style>`, in the artifact itself: it loads stylesheets only from Google Fonts and scripts only from a few CDNs, so no `<link>` or `<script src>` to Kodza.
- Writing the artifact file with a shell? Insert the downloaded files (Files) with a command instead of retyping them: a line `KODZA-CSS` alone inside the `<style>`, then `sed -e '/^KODZA-CSS$/{r kodza/kodza-soft-swap.css' -e 'd' -e '}' page.html > artifact.html`.
- Font: load Geist from Google Fonts (allowed): `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap">`.
- Dark mode: the viewer sets `data-theme="dark"` or `"light"` on `<html>`, or nothing when it follows the system. Write the Dark mode rules twice: inside `@media (prefers-color-scheme: dark)` with `:root:not([data-theme="light"])` in front of each selector, and with `:root[data-theme="dark"]` in front of each selector.

### WordPress

- HTML: a **Custom HTML** block (or a pattern / template part). WordPress keeps this markup as it is only for users allowed to post unfiltered HTML (Administrators; Super Admins on multisite): for anyone else it strips tags such as `<svg>`, `<input>` or `<dialog>`, so add it from the theme (a pattern or template part) instead.
- This one spans the page: a block theme keeps a Custom HTML block to the width of the text column, so put it inside a **Group** block set to **Full width**, with **Inner blocks use content width** turned off.
- CSS: the downloaded file goes in the theme, under `wp-content/themes/<your-theme>/kodza/` (the child theme if there is one), enqueued from its functions.php (or a code-snippets plugin):
  ```php
  add_action('wp_enqueue_scripts', function () {
    $dir = get_stylesheet_directory() . '/kodza/';
    $uri = get_stylesheet_directory_uri() . '/kodza/';
    wp_enqueue_style('kodza-soft-swap', $uri . 'kodza-soft-swap.css', [], filemtime($dir . 'kodza-soft-swap.css'));
  });
  ```
  Scoped to `kodza-` classes, the CSS never touches the theme.
- No access to the theme files? The CSS can also go, as it is, in **Appearance → Customize → Additional CSS** (classic theme) or **Styles → Additional CSS** in the Site Editor (block theme).
- No JS.
- Colours: it takes the colour of the text around it (or keeps its own monochrome): nothing to point at the theme.
- WordPress turns straight quotes and `--` into typographic ones in the text of a Custom HTML block (not inside `<code>` or `<pre>`).
- Paste the CSS on every page (Additional CSS works): every link on the site swaps, nothing else to do. Add the class kodza-soft-swap to your site header (block themes: Header template part, Advanced, Additional CSS class), one per page. Browser Back plays it forward; with Swup or Barba, start your swap with document.startViewTransition({ update, types: ['back'] }) to drift it the other way. Firefox has no transition between two documents yet: it loads the next page without it. Another Kodza page transition on the page takes over.

#### With Elementor

- HTML: an **HTML** widget (Elementor shows it to administrators only). It keeps the code exactly as pasted, straight quotes included.
- This one spans the page: give the widget its own container, **Content Width: Full Width**, padding 0.
- CSS: as above. Elementor's Site Settings → Theme Style styles every button, field, link and image of the page, components included; the theme guard at the end of the CSS keeps the component's buttons, fields and link colours as they are.
- No child theme: the CSS can go in the HTML widget instead, in a `<style>` before the HTML.

### Shopify

- Files: `assets/kodza-soft-swap.css` (the Files commands with `-o assets/…`).
- In the section: `{{ 'kodza-soft-swap.css' | asset_url | stylesheet_tag }}`, then the HTML.

### React / Next.js

- Files, side by side (e.g. in `components/kodza/`): `SoftSwap.tsx` (below), the CSS block under Code saved as `soft-swap.css`, both as they are. The HTML block is already inside the component: skip it.
- Use it: `import { SoftSwap } from '@/components/kodza/SoftSwap';` then `<SoftSwap />`. Props: `className`, `style` and the other props of its `<header>` (`onClick`, `aria-…`).
- Next.js App Router: it is a Client Component already (`'use client'`). Pages Router: Next.js takes global CSS only in `pages/_app`, so move the `import './soft-swap.css'` line there.

```tsx
'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} />
  );
}
```

## Code

```html
<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>
```

```css
/* 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. */
```

Free to use, from Kodza (https://kodza.dev).
