Skip to content

Swap

Two faces stacked in one spot, exchanged with an animation. Underneath is a real checkbox stretched over both faces, so the click target, the Space key, focus and the state all come from the browser. There is no toggle handler.

  • Stable
  • React 19
  • Zero-JS when uncontrolled
  • Composes into Button

Click any of them — no JavaScript runs.

Code
<Swap
aria-label="Toggle menu"
effect="rotate"
on={<CloseIcon />}
off={<MenuIcon />}
/>
<label class="wayra-swap" data-effect="rotate">
<input type="checkbox" class="wayra-swap-input" aria-label="Toggle menu" />
<span class="wayra-swap-on"><svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M3.3 1.9 8 6.6l4.7-4.7 1.4 1.4L9.4 8l4.7 4.7-1.4 1.4L8 9.4l-4.7 4.7-1.4-1.4L6.6 8 1.9 3.3l1.4-1.4Z"/></svg></span>
<span class="wayra-swap-off"><svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M1 3h14v2H1V3Zm0 4h14v2H1V7Zm0 4h14v2H1v-2Z"/></svg></span>
</label>

Pass the two faces and nothing else. Both faces render into the same grid cell, so the box takes the size of the larger one and nothing reflows when they trade places. The checkbox that drives them sits on top of both faces, invisible but real, which is where the click target and the tab stop come from.

Change the props and copy the code.

Leave controlled off and the swap runs on the checkbox alone — no state, no handler, no JavaScript.

effect
faces
state
Generated code
TSX
<Swap
  aria-label="Toggle theme"
  on={<SunIcon />}
  off={<MoonIcon />}
/>
Code
<Swap effect="fade" aria-label="fade" on={<SunIcon />} off={<MoonIcon />} />
<Swap effect="rotate" aria-label="rotate" on={<SunIcon />} off={<MoonIcon />} />
<Swap effect="flip" aria-label="flip" on={<SunIcon />} off={<MoonIcon />} />
<label class="wayra-swap" data-effect="fade">
<input type="checkbox" class="wayra-swap-input" aria-label="fade example" />
<span class="wayra-swap-on"><svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor" aria-hidden="true"><circle cx="8" cy="8" r="3.2"/><path d="M7 0h2v2.6H7V0Zm0 13.4h2V16H7v-2.6ZM0 7h2.6v2H0V7Zm13.4 0H16v2h-2.6V7Z"/></svg></span>
<span class="wayra-swap-off"><svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M13.5 10.5A6 6 0 0 1 5.5 2.5a6 6 0 1 0 8 8Z"/></svg></span>
</label>
<label class="wayra-swap" data-effect="rotate">
<input type="checkbox" class="wayra-swap-input" aria-label="rotate example" />
<span class="wayra-swap-on"><svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor" aria-hidden="true"><circle cx="8" cy="8" r="3.2"/><path d="M7 0h2v2.6H7V0Zm0 13.4h2V16H7v-2.6ZM0 7h2.6v2H0V7Zm13.4 0H16v2h-2.6V7Z"/></svg></span>
<span class="wayra-swap-off"><svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M13.5 10.5A6 6 0 0 1 5.5 2.5a6 6 0 1 0 8 8Z"/></svg></span>
</label>
<label class="wayra-swap" data-effect="flip">
<input type="checkbox" class="wayra-swap-input" aria-label="flip example" />
<span class="wayra-swap-on"><svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor" aria-hidden="true"><circle cx="8" cy="8" r="3.2"/><path d="M7 0h2v2.6H7V0Zm0 13.4h2V16H7v-2.6ZM0 7h2.6v2H0V7Zm13.4 0H16v2h-2.6V7Z"/></svg></span>
<span class="wayra-swap-off"><svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M13.5 10.5A6 6 0 0 1 5.5 2.5a6 6 0 1 0 8 8Z"/></svg></span>
</label>
effect What it does
fade The default. A plain crossfade, and the only effect with no transform at all.
rotate The incoming face turns in from a quarter-turn while the outgoing turns away.
flip A half-turn around the vertical axis.

One enum instead of two booleans, because the effects were never meaningfully combinable.

rotate turns the two faces in opposite directions, so the swap reads as one thing replacing another rather than two icons sliding the same way. flip runs at twice the base duration, because a 180° turn in 140ms is a smear. Both faces always share one duration: matching timings look like a single object being replaced.

Leave active out and the checkbox toggles itself. The :checked half of the stylesheet takes over and no JavaScript runs at all. defaultChecked picks the starting face.

{/* uncontrolled — nothing to hydrate */}
<Swap aria-label="Toggle menu" on={<CloseIcon />} off={<MenuIcon />} />
{/* controlled */}
<Swap
aria-label="Toggle menu"
active={open}
onChange={(e) => setOpen(e.target.checked)}
on={<CloseIcon />}
off={<MenuIcon />}
/>

active drives the root’s data-active and the checkbox’s checked together, so the two can never disagree. Passing active with no onChange is allowed: it describes a swap whose state is owned further up. The component marks the input readOnly in that case, rather than leaving React’s controlled-input warning for you to silence.

react-hook-form is built in. Inside Form — which provides the useForm instance you own — a Swap registers itself on name alone and submits a boolean. It also freezes while the form submits, and noRhf turns all of it off for one instance.

Leave active out in this mode. The state lives in the DOM, react-hook-form reads the checkbox directly, and the :has() half above is what exchanges the faces — so data-active never appears. defaultChecked, or the form’s defaultValues, picks the starting face.

const form = useForm({ defaultValues: { darkMode: false } });
<Form form={form} onSubmit={save}>
<Swap aria-label="Dark mode" name="darkMode" on={<MoonIcon />} off={<SunIcon />} />
<Button type="submit">Save</Button>
</Form>

Swap takes no error from the form, and that is the one difference from Toggle. There is no label, no hint slot and no invalid styling here, so a validation message about this field belongs beside the form rather than on the control. interactive={false} registers nothing either: with no input there is no field, and a name beside it is inert.

aria-pressed=false

Code
import { useState } from "react";
import { Button, Swap } from "wayra-ui";
const PlayIcon = () => (
<svg viewBox="0 0 16 16" width={16} height={16} fill="currentColor">
<title>Play</title>
<path d="M6 3l5 5-5 5V3Z" />
</svg>
);
const PauseIcon = () => (
<svg viewBox="0 0 16 16" width={16} height={16} fill="currentColor">
<title>Pause</title>
<path d="M4 3h3v10H4V3Zm5 0h3v10H9V3Z" />
</svg>
);
export function ComposedDemo() {
const [playing, setPlaying] = useState(false);
return (
<div className="hu-demo-col">
<Button
shape="circle"
size="lg"
aria-label={playing ? "Pause" : "Play"}
pressed={playing}
onClick={() => setPlaying((p) => !p)}
>
{/* interactive={false} drops the input and renders a <span>. A label
wrapping an input inside a button is invalid HTML and an axe
nested-interactive failure — this is the only way to compose. */}
<Swap
interactive={false}
active={playing}
effect="rotate"
on={<PauseIcon />}
off={<PlayIcon />}
/>
</Button>
<p className="hu-readout">aria-pressed={String(playing)}</p>
</div>
);
}

interactive={false} drops the input and renders the root as a plain <span>. That is not cosmetic. A <label> wrapping an <input> inside a <button> is invalid HTML and an axe nested-interactive failure. Composing a swap into a control that already exists goes through this mode only, and with nothing left to label, the root stops being a <label>.

A decorative swap also loses the pointer cursor. It is a picture, and a pointer over a picture promises a click that never happens.

Swap shares none of the field machinery. There is no label wrapper, no helper slot and no --_wayra-field-*, and it takes no size and no color either. The faces inherit font-size and currentColor from wherever you put them:

The icons here are fixed-size SVGs; text faces scale with the inherited font-size.

Code
<span style={{ fontSize: '1rem', color: 'var(--wayra-color-primary)' }}>
<Swap aria-label="Small" on={<CloseIcon />} off={<MenuIcon />} />
</span>
<span style="font-size: 1rem; color: var(--wayra-color-primary)"><label class="wayra-swap" data-effect="rotate">
<input type="checkbox" class="wayra-swap-input" aria-label="Small inherited" />
<span class="wayra-swap-on"><svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M3.3 1.9 8 6.6l4.7-4.7 1.4 1.4L9.4 8l4.7 4.7-1.4 1.4L8 9.4l-4.7 4.7-1.4-1.4L6.6 8 1.9 3.3l1.4-1.4Z"/></svg></span>
<span class="wayra-swap-off"><svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M1 3h14v2H1V3Zm0 4h14v2H1V7Zm0 4h14v2H1v-2Z"/></svg></span>
</label></span>
<span style="font-size: 2rem; color: var(--wayra-color-error)"><label class="wayra-swap" data-effect="rotate">
<input type="checkbox" class="wayra-swap-input" aria-label="Large inherited" />
<span class="wayra-swap-on"><svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M3.3 1.9 8 6.6l4.7-4.7 1.4 1.4L9.4 8l4.7 4.7-1.4 1.4L8 9.4l-4.7 4.7-1.4-1.4L6.6 8 1.9 3.3l1.4-1.4Z"/></svg></span>
<span class="wayra-swap-off"><svg viewBox="0 0 16 16" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M1 3h14v2H1V3Zm0 4h14v2H1V7Zm0 4h14v2H1v-2Z"/></svg></span>
</label></span>

SwapProps extends ComponentProps<"input"> minus checked, children, color and type. Props land on the input, not on the root: aria-label, name, onChange, disabled and id all belong to the control. className is the exception, because it composes with the root’s class and the root is what the styling contract names.

Prop Type Default Description
on ReactNode — Required. The face shown while the swap is on.
off ReactNode — Required. The resting face.
active boolean — Controlled state. Leave it out for an uncontrolled swap.
effect "fade" | "rotate" | "flip" "fade" How the faces exchange.
interactive boolean true false drops the input and renders a <span>.
noRhf boolean false Ignores a surrounding Form: nothing registers.
unstyled boolean false Drops the classes on all four elements.
Key Behavior
Tab Reaches the swap. The invisible checkbox is a real tab stop.
Space Toggles it, because it is a checkbox and not a click handler.
  • Focus. The ring is drawn on the root, which is the box the user can see. The invisible input gets outline: none.
  • Naming. Icons give the control nothing to be called, so aria-label is required.
  • The hidden face leaves the accessibility tree. visibility is transitioned alongside opacity so the outgoing face stays painted for the whole animation, then stops being announced. Otherwise both faces would feed the name.
  • Windows high contrast. Forced colors drops box-shadow, so the ring is redrawn from CanvasText. A disabled swap turns GrayText.
  • Reduced motion keeps the function and drops the motion. The faces still swap, as a 160ms cross-fade that never turns, flip included.
  • Composing into another control goes through interactive={false}. Anything else is invalid HTML.

Do

An aria-label that names the action. Screen reader users hear “Toggle navigation menu”.

Don't

No label. Two icons name nothing, so the control is announced as an unnamed checkbox.

Do

Inside a Button, pass interactive={false}. The root becomes a span and the Button stays the only control.

Don't

A label and an input inside a button: invalid HTML, and an axe nested-interactive failure.