Fork me on GitHub
component  v1.0.0 typescript no dependencies

<before-after>

A custom element that wipes between two versions of the same thing — a photo before and after grading, a screen before and after a redesign, a function before and after a refactor. Drag the handle or focus it and use the arrow keys.

source ~12 KB shadow DOM open a11y role=slider license MIT
Fig. 1  ·  ungraded capture / final grade value 42   last event
Installone file

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.

Download before-after.es.js latest release · no dependencies · all releases

<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>
Examplesslots, axis

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 };
}


Fig. 2  ·  slotted markup, horizontal axis

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.

Fig. 3  ·  vertical axis

Grab anywhere

grab="anywhere" allows the user to drag the divider from anywhere within the component, not just the handle.

Fig. 4  ·  custom properties + grab="anywhere"
APIattributes
AttributeTypeDefaultNotes
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

MemberKindNotes
valueget / setLive position as a number. Setting it moves the divider without firing an event.
orientationget / setReflects the attribute.
disabledget / setReflects the attribute.
stepget / setReflects the attribute. Falls back to 1 for anything that is not a usable number.
inputeventFires on every movement, including each arrow key press. event.detail.value carries the position.
changeeventFires once the gesture ends — pointer released, or key released — and only if the divider actually moved. Use this one for anything expensive.

Styling

Custom propertyDefaultAffects
--ba-divider-width2pxThickness of the seam
--ba-divider-color#fffSeam colour
--ba-handle-size44pxHandle diameter
--ba-handle-bg#fffHandle fill
--ba-handle-color#111Chevron colour
--ba-radius0Frame corner radius
--ba-backgroundtransparentFrame fill behind the panes
--ba-object-fitcoverFit of slotted media
--ba-label-bgrgb(0 0 0 / .55)Caption chip background
--ba-label-color#fffCaption chip text
--ba-label-fontui-monospace, …Caption chip typeface
--ba-focus-ring#0aaFocus outline colour

For anything the custom properties do not reach, the shadow parts are exposed: frame, pane, before, after, divider, handle, label and image.

Behaviourkeys, edges

Keyboard

KeyResult
Move one step toward the before side
Move one step toward the after side
Page Up Page DownMove ten steps
Home EndJump to 0% or 100%

Worth knowing

  • The handle is a real <button> carrying role="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-path on 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-motion nothing is eased.
  • The divider position is a percentage of the frame, never a pixel offset, so it holds through layout changes and zoom.
Sourcebefore-after.es.js

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.

before-after.es.js Download