Compare Slider
component / marketing
Two images in one frame with a draggable wipe between them, driven by a real range input — so it arrives keyboard-operable, touch-capable and announced, with the frame reserved before a byte loads.
- type
- component
- files
- 1
- registry deps
- skeleton
- npm deps
- lucide-react
installs
- components/automindz/compare-slider.tsx
- A real <input type=range>: arrow keys, Home/End, touch, and an announced percentage
- width/height reserve the frame before a byte loads, so nothing under it jumps
- A missing or failed image is a labelled placeholder that blocks the wipe with a reason
- The visible handle is decorative — the focus ring is drawn through the frame's :has()
- No stylesheet: the wipe is a clip-path driven by the input's own value
before, after, width and height have no defaults. Only the ratio of width/height is used, and it is used **before either image has loaded** — that is the entire point of them: without it the figure is 0px tall until the first byte arrives and everything under it jumps. There is no stylesheet to import and no config file. It is "use client", for one reason worth knowing: the control is a native <input type="range"> and CSS cannot read a live value (the IDL attribute is not the content attribute, so attr() never sees it change), so the wipe needs one handler and one piece of state. That input is invisible, opacity-0 rather than hidden, stretched over the whole frame with a 2px thumb so the value maps to the pointer across the full width — it brings arrow keys, Home/End, Page Up/Down, touch and an announced value that a div with a mousedown listener never gets. The visible handle is decorative and the focus ring is drawn on it through the frame's :has(). alt on each side is real alt text describing **that** picture, not "before" — that is label, and it is already on screen. A missing or failed src renders a labelled placeholder **and** blocks the control with a reason, because a wipe between a photograph and a grey box compares nothing; disabledReason does the same by hand, and both land in the figcaption as a role="status". Both images are cover-fitted to one frame so the comparison is between the same region of two pictures — set --automindz-compare-fit: contain if the pair really is two different shapes. --automindz-compare-handle (default 2px) is the handle's line width. --automindz-compare-position is **not** a dial: the input owns the position, and a stylesheet that moved the wipe without moving the announced value would leave the two disagreeing.
Variations
one state per section
Default, 16:9
Tab to it and use the arrow keys, Home and End: the control is a real input[type=range] laid invisibly over the whole frame, so the wipe is keyboard-operable and announced as a percentage without a single pointer listener. The focus ring lands on the handle because the element with focus is transparent. The percentage counts the before image — the side left of the seam, and the side that grows as the value does — so what is announced is what is on screen.
A mismatched pair
A square after image against a 16:9 before. Both are cover-fitted into one frame at the before image's ratio, so the seam stays over picture rather than over a letterbox bar, and the two never disagree about where a feature sits. --automindz-compare-fit is the escape hatch.
Loading
The skeleton is the frame, at its declared ratio — the same 16:9 box the images will land in. Nothing below it moves when they arrive, which is what the required width and height buy.
One image missing
A labelled placeholder, not the browser's broken-image glyph — and the control blocks itself with the reason in the caption, because a wipe between a photograph and a grey box compares nothing. The reason is a role=status, since a side can fail after the frame is already on screen. It stacks above the caller's own caption rather than replacing it: the reason is a state this component discovered, the caption is content somebody wrote, and aria-describedby was already promising both.
Disabled, with a reason
Shown, blocked, explained: one prop drives the disabled input and the sentence under it, so they cannot disagree. The handle stays where it was and dims rather than disappearing — hiding the control makes people ask where it went.
