Drop before-after.es.js next to your page and load it as a module. The element
registers itself; there is nothing to initialise and nothing to import.
<script type="module" src="before-after.es.js"></script>
<before-after
before-src="raw.jpg" before-alt="Straight out of camera"
after-src="graded.jpg" after-alt="Colour graded"
before-label="Original"
after-label="Graded"
value="50"></before-after>
Both panes accept arbitrary markup instead, via the before and
after slots. Whatever is in them gets clipped, so the two sides should share
the same geometry — same box, same padding, same line height — or the wipe will not line up.
<before-after>
<div slot="before"><!-- any HTML --></div>
<div slot="after"><!-- any HTML --></div>
</before-after>
Two code paths, same shape
Slotted HTML, no images. The panes here are two <pre> blocks padded to the
same line count, so the divider reads as a single edit rather than two documents.
function loadProfile(id, cb) {
fetchUser(id, function (err, user) {
if (err) return cb(err);
fetchPosts(user, function (err, posts) {
if (err) return cb(err);
fetchStats(user, function (err, stats) {
if (err) return cb(err);
cb(null, { user, posts, stats });
});
});
});
}
async function loadProfile(id) {
const user = await fetchUser(id);
const [posts, stats] = await Promise.all([
fetchPosts(user),
fetchStats(user),
]);
return { user, posts, stats };
}
Wiping down instead of across
orientation="vertical" moves the divider top to bottom; the before pane is the
top one. Everything else — keys, events, parts — behaves identically.
Grab anywhere
grab="anywhere" allows the user to drag the divider from anywhere within the component, not just the handle.
grab="anywhere"| Attribute | Type | Default | Notes |
|---|---|---|---|
| value | 0–100 | 50 | Divider position as a percentage. Sets the starting position and moves the divider whenever it changes; it is not written back during a drag — read the value property for that. |
| orientation | horizontal | vertical | horizontal | Axis the divider travels along. Vertical puts the before pane on top. |
| before-src / after-src | url | — | Shorthand for a plain image pane. The image and any slotted content for that side share the pane and stack, so use one or the other per side. |
| before-alt / after-alt | string | "" | Alt text for those images. Empty alt marks the image decorative. |
| before-label / after-label | string | — | Caption chips pinned to the top corners. Each one sits inside its own pane, so it wipes away with it. |
| label | string | Before and after comparison | Accessible name for the slider. |
| grab | handle | anywhere | handle | Where a drag can start. The default keeps clicks on slotted links and buttons working; anywhere trades that for a larger target. |
| step | number | 1 | Percentage points per arrow key press. Page Up / Page Down move ten steps. |
| aspect | ratio | — | Forces the frame ratio, e.g. 16 / 9. Without it the element takes its ratio from the first image, or from the taller pane when the panes are markup. |
| disabled | boolean | false | Freezes the divider and removes the handle from the tab order. |
Properties and events
| Member | Kind | Notes |
|---|---|---|
| value | get / set | Live position as a number. Setting it moves the divider without firing an event. |
| orientation | get / set | Reflects the attribute. |
| disabled | get / set | Reflects the attribute. |
| step | get / set | Reflects the attribute. Falls back to 1 for anything that is not a usable number. |
| input | event | Fires on every movement, including each arrow key press. event.detail.value carries the position. |
| change | event | Fires once the gesture ends — pointer released, or key released — and only if the divider actually moved. Use this one for anything expensive. |
Styling
| Custom property | Default | Affects |
|---|---|---|
| --ba-divider-width | 2px | Thickness of the seam |
| --ba-divider-color | #fff | Seam colour |
| --ba-handle-size | 44px | Handle diameter |
| --ba-handle-bg | #fff | Handle fill |
| --ba-handle-color | #111 | Chevron colour |
| --ba-radius | 0 | Frame corner radius |
| --ba-background | transparent | Frame fill behind the panes |
| --ba-object-fit | cover | Fit of slotted media |
| --ba-label-bg | rgb(0 0 0 / .55) | Caption chip background |
| --ba-label-color | #fff | Caption chip text |
| --ba-label-font | ui-monospace, … | Caption chip typeface |
| --ba-focus-ring | #0aa | Focus outline colour |
For anything the custom properties do not reach, the shadow parts are exposed:
frame, pane, before, after,
divider, handle, label and image.
Keyboard
| Key | Result |
|---|---|
| ← ↑ | Move one step toward the before side |
| → ↓ | Move one step toward the after side |
| Page Up Page Down | Move ten steps |
| Home End | Jump to 0% or 100% |
Worth knowing
- The handle is a real
<button>carryingrole="slider", so it is tabbable, announces its position, and works inside a form without submitting it. - Panes are stacked in one grid cell, so the frame is as tall as the taller side and nothing is cropped by surprise.
- Clipping is
clip-pathon percentages — resizing the element needs no JavaScript and no re-measure. - Pointer capture is taken on the frame, so a drag survives the pointer leaving the element.
- Keyboard moves are eased; pointer drags are not. Under
prefers-reduced-motionnothing is eased. - The divider position is a percentage of the frame, never a pixel offset, so it holds through layout changes and zoom.
This is the exact module running the demos above — the page reads its own script tag to fill the listing, so what you copy is what you just used.