defuss-shadcn / Actions / swap
SwapATM
Two faces in one spot - a sun and a moon, play and pause, a hamburger and a close icon - and a click swaps them. A hidden checkbox inside a <label> is the whole state machine: :checked, :indeterminate and :has() pick the visible face, CSS animates the change. No JavaScript.
On this page (9)
§Default
The faces crossfade in place - click one: the hidden checkbox is the state. The box sizes to the widest face, so nothing shifts.
§Icons
Any content is a face. Hide decorative glyphs with aria-hidden and name the checkbox.
§Rotate
data-variant='rotate' swings the faces in and out over ±45° - the theme toggle and the menu button.
§Flip
data-variant='flip' turns the control over on its Y axis, like a card.
§Sizes
data-size sm · (default) · lg sets the minimum box, the type and the icon scale; the control still grows to its widest face.
§Third face (indeterminate)
A .swap-indeterminate face shows while the checkbox is indeterminate - an IDL property only, so one line of script sets it. The first click leaves the mixed state for on.
§Active (forced)
data-active shows the on face without a checked checkbox - server-rendered state; remove it to hand control back to the checkbox.
§Disabled
disabled on the checkbox dims the control and blocks the pointer - it keeps its current face.
§CSS view file
/* -- Swap component -------------------------------------------- */@layer components { .swap { /* the faces stack in one grid cell, so the box sizes to the widest one and no absolute positioning / JS measurement is needed */ position: relative; display: inline-grid; place-items: center; min-width: 2.25rem; min-height: 2.25rem; border-radius: var(--radius-md); font-family: var(--font-sans); font-size: 0.875rem; font-weight: 500; line-height: 1; color: var(--foreground); cursor: pointer; user-select: none; vertical-align: middle; /* flip needs depth; harmless for the other variants */ perspective: 40rem; /* the checkbox IS the state — visually hidden but focusable and hit-testable across the whole control (no .sr-only dependency) */ & > input[type="checkbox"] { grid-area: 1 / 1; appearance: none; -webkit-appearance: none; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; opacity: 0; cursor: inherit; } & .swap-on, & .swap-off, & .swap-indeterminate { grid-area: 1 / 1; display: inline-flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; backface-visibility: hidden; transition: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1), rotate 300ms cubic-bezier(0.4, 0, 0.2, 1), scale 300ms cubic-bezier(0.4, 0, 0.2, 1); } & svg:not([class*="size-"]) { width: 1.25rem; height: 1.25rem; flex-shrink: 0; } /* -- Faces: default (off) ------------------------------- */ & .swap-off { opacity: 1; } /* -- Faces: indeterminate ------------------------------- */ &:has(> input:indeterminate) { & .swap-off { opacity: 0; } & .swap-indeterminate { opacity: 1; } } /* -- Faces: on (checked, or forced via data-active) ------ */ &:has(> input:checked), &[data-active] { & .swap-off, & .swap-indeterminate { opacity: 0; } & .swap-on { opacity: 1; } } /* -- Variant: rotate ------------------------------------ */ &[data-variant="rotate"] { & .swap-on, & .swap-indeterminate { rotate: -45deg; } &:has(> input:indeterminate) { & .swap-off { rotate: 45deg; } & .swap-indeterminate { rotate: 0deg; } } &:has(> input:checked), &[data-active] { & .swap-off, & .swap-indeterminate { rotate: 45deg; } & .swap-on { rotate: 0deg; } } } /* -- Variant: flip -------------------------------------- */ &[data-variant="flip"] { & .swap-on, & .swap-indeterminate { rotate: y 180deg; } &:has(> input:indeterminate) { & .swap-off { rotate: y -180deg; } & .swap-indeterminate { rotate: y 0deg; } } &:has(> input:checked), &[data-active] { & .swap-off, & .swap-indeterminate { rotate: y -180deg; } & .swap-on { rotate: y 0deg; } } } /* -- Sizes ---------------------------------------------- */ &[data-size="sm"] { min-width: 2rem; min-height: 2rem; font-size: 0.75rem; & svg:not([class*="size-"]) { width: 1rem; height: 1rem; } } &[data-size="lg"] { min-width: 2.75rem; min-height: 2.75rem; font-size: 1.125rem; & svg:not([class*="size-"]) { width: 1.5rem; height: 1.5rem; } } /* -- Interaction states --------------------------------- */ &:hover:not(:has(> input:disabled)) { background-color: var(--accent); color: var(--accent-foreground); } &:has(> input:focus-visible) { outline: 2px solid var(--ring); outline-offset: 2px; } /* a disabled input never toggles - no pointer-events: none, so the not-allowed cursor shows */ &:has(> input:disabled) { opacity: 0.5; cursor: not-allowed; } } /* Accessibility: honour a reduced-motion request — the faces still swap, they just do not animate (near-zero duration keeps transitionend firing). */ @media (prefers-reduced-motion: reduce) { .swap .swap-on, .swap .swap-off, .swap .swap-indeterminate { transition-duration: 0.01ms !important; } } @media (prefers-contrast: more) { .swap { border: 2px solid transparent; &:has(> input:checked), &[data-active] { border-color: currentColor; } } } @media (forced-colors: active) { .swap { border: 1px solid ButtonText; color: ButtonText; forced-color-adjust: none; &:has(> input:checked), &[data-active] { background: Highlight; color: HighlightText; } &:has(> input:disabled) { border-color: GrayText; color: GrayText; } } }}Comments, ideas or improvements? Edit this page's source on GitHub