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
<Swaparia-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.
Playground
Section titled “Playground”Change the props and copy the code.
Leave controlled off and the swap runs on the checkbox alone — no state, no handler, no JavaScript.
Generated code
<Swap
aria-label="Toggle theme"
on={<SunIcon />}
off={<MoonIcon />}
/>Effects
Section titled “Effects”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.
Controlled and uncontrolled
Section titled “Controlled and uncontrolled”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.
Composing into a Button
Section titled “Composing into a Button”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.
What’s not included
Section titled “What’s not included”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>API reference
Section titled “API reference”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. |
Accessibility
Section titled “Accessibility”| 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-labelis required. - The hidden face leaves the accessibility tree.
visibilityis transitioned alongsideopacityso 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 fromCanvasText. A disabled swap turnsGrayText. - Reduced motion keeps the function and drops the motion. The faces still swap, as a
160ms cross-fade that never turns,
flipincluded. - Composing into another control goes through
interactive={false}. Anything else is invalid HTML.
Do and don’t
Section titled “Do and don’t”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.