Press / to search

    No results

    Search failed

    npm GitHub

    Before and after

    Drag the seam and the picture changes from the graded version to the original. On a narrow screen it is the same thing turned on its side โ€” a top and a bottom rather than a left and a right.

    It is <splitter-elemental>, four rules of your own CSS for the illusion and seven more for the knob over the seam โ€” and it is worth knowing why it works, because what it is doing is not what it looks like.

    <splitter-elemental class="reveal" vertical-when="(width < 32rem)" label-text="Reveal the graded picture">
      <div><img src="https://picsum.photos/id/62/1200/800" alt="Rolling hills at dawn, before grading"></div>
      <div><img class="graded" src="https://picsum.photos/id/62/1200/800" alt="The same hills, graded to black and white"></div>
    </splitter-elemental>
    /* the handle and the two tracks are the element's; the illusion is these four rules.
       `size` rather than `inline-size` once there is a handle, because the picture is measured
       against both axes below and `cqh` needs the block one โ€” and `aspect-ratio` is what makes
       that height definite. Ungated it would be size containment with nothing to resolve
       against, which is a splitter 0 tall before the script arrives */
    splitter-elemental.reveal { container-type: inline-size; }
    .reveal[data-splitter-panes] { container-type: size; aspect-ratio: 3 / 2; }
    .reveal[data-splitter-panes] > div:not([data-splitter-handle]) { position: relative; overflow: hidden; }
    
    /* each pane holds a picture the size of the whole splitter, pinned to the corner its pane
       never leaves โ€” so the two halves are the same frame and line up across the seam. Both
       corners, so the pins turn with the panes: pinning the first to the top left and the second
       to the bottom right is the same instruction side by side and stacked */
    .reveal[data-splitter-panes] img {
      position: absolute;
      inline-size: 100cqw;
      max-inline-size: none;
      block-size: 100cqh;
      object-fit: cover;
    }
    .reveal[data-splitter-panes] > div:first-child img { inset-block-start: 0; inset-inline-start: 0; }
    .reveal[data-splitter-panes] > div:last-child img { inset-block-end: 0; inset-inline-end: 0; }
    
    /* the "after". A filter here so the sample needs one photograph rather than two */
    .reveal .graded { filter: grayscale(1) contrast(1.15); }
    
    /* a one-pixel seam, and the target moved off it into a knob. The handle's column is 1px
       wide, so the band of missing picture is a hairline; its `::before` is a 40px circle that
       overflows that box, and a pseudo element hit-tests as part of the element it belongs to,
       so the circle is what a pointer has to hit. Which is why the pane rule above says
       `:not([data-splitter-handle])` โ€” the handle is a `<div>` as well, and an `overflow:
       hidden` meant for a pane would clip the knob back to one pixel */
    .reveal[data-splitter-panes] { --splitter-elemental-size: 1px; }
    
    .reveal[data-splitter-panes] > [data-splitter-handle]::before,
    .reveal[data-splitter-panes] > [data-splitter-handle]::after {
      position: absolute;
      inset-block-start: 50%;
      inset-inline-start: 50%;
      translate: -50% -50%;
      content: "";
    }
    
    /* the theme's three dots are this same `::before`, so this replaces them rather than
       landing beside them. `Canvas` and `CanvasText` are the page's own pair and follow a theme
       switch; the ring and the shadow are what keep a white knob off a pale sky */
    .reveal[data-splitter-panes] > [data-splitter-handle]::before {
      inline-size: 2.5rem;
      block-size: 2.5rem;
      border-radius: 50%;
      background: Canvas;
      box-shadow:
        0 0 0 1px color-mix(in srgb, CanvasText 20%, transparent),
        0 1px 3px rgb(0 0 0 / 0.3);
    }
    
    /* Octicon `arrow-both`, drawn as a mask rather than as a picture, so the ink is a colour
       this page sets and not one baked into the data URI */
    .reveal[data-splitter-panes] > [data-splitter-handle]::after {
      inline-size: 1.5rem;
      block-size: 1.5rem;
      background: CanvasText;
      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.72 3.72a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.56 7h10.88l-2.22-2.22a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018l3.5 3.5a.75.75 0 0 1 0 1.06l-3.5 3.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l2.22-2.22H2.56l2.22 2.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-3.5-3.5a.75.75 0 0 1 0-1.06Z'/%3E%3C/svg%3E") center / contain no-repeat;
    }
    
    /* stacked, the arrows point the way the panes now move */
    .reveal[vertical][data-splitter-panes] > [data-splitter-handle]::after { rotate: 90deg; }
    
    /* the browser's ring would otherwise be drawn round the 1px handle, which is a hairline
       nobody can see. On the knob it is the size of the control it marks */
    .reveal[data-splitter-panes] > [data-splitter-handle]:focus-visible { outline: none; }
    .reveal[data-splitter-panes] > [data-splitter-handle]:focus-visible::before {
      outline: 3px solid Highlight;
      outline-offset: 2px;
    }
    
    /* ungated, so the picture is still a picture before the script arrives */
    .reveal img { display: block; inline-size: 100%; }

    Photograph by Daniel Genser, from Unsplash, served here through Lorem Picsum.

    Nothing is being revealed. The two panes genuinely resize, exactly as they do on the splitter's own page; the illusion is that each one holds a picture the size of the whole splitter, pinned to the corner its pane never leaves โ€” the first to the top left, the second to the bottom right. 100cqw and 100cqh are what make "the size of the whole splitter" numbers the panes cannot change, which is why the two container-type declarations are the first rules and not a detail: inline-size on the bare tag, so the no-script version still takes its height from the pictures in it, and size once there is a handle, because cqh needs a block axis and aspect-ratio is what makes that one definite. Measured on a 600px splitter: both pictures 600 wide at the same x, and still there after the separator has moved to 144.

    It turns with the screen. vertical-when="(width < 32rem)" is the only thing in the markup that is not the element's usual two panes, and while that matches the reveal is a top and a bottom rather than a left and a right โ€” the same frame, the same drag, one axis over. Nothing in the CSS is about the new axis: a corner pin is the same instruction read either way round, and the height does not change with the flip because aspect-ratio is what sets it โ€” which is also the height a stacked splitter needs and would otherwise have to be given.

    The handle is a band of missing picture, so here it is one pixel of one. It has a column of its own wherever this element is used, and the two halves are --splitter-elemental-size apart rather than edge to edge โ€” where a real reveal has a hairline over a continuous image. --splitter-elemental-size: 1px is as close to that hairline as this can get.

    What the band was carrying was the target, and the knob takes it back. Narrowing the handle to a pixel spends the 24px target that made it a control anyone can hit โ€” so the CSS puts a 40px circle on the handle's ::before, overflowing its 1px box, and a pseudo element hit-tests as part of the element it belongs to. The circle is what a pointer has to hit; the pixel is only what it looks like. The browser's focus ring is moved onto the knob for the same reason: drawn round a 1px handle it is a hairline nobody can see.

    That arrangement is <compare-images-slider>'s own โ€” a 2px handle under a 42px knob โ€” and the arrows in the middle are the same Octicon arrow-both, masked rather than embedded so the ink is a colour this page sets. Canvas and CanvasText are that colour pair, so the knob turns with the theme; the ring and the shadow are what keep a white circle off a pale sky.

    It is images only. A pane holding text would reflow as it narrows, because it is genuinely narrowing โ€” there is nothing to pin. That is the difference from a real reveal, where the layer in front is full size and clipped.

    Without script it is two pictures, one above the other โ€” before over after, in reading order, which is the honest flat version of a before/after. Every rule above but the last is gated on data-splitter-panes, so none of them applies until there is a handle; the ungated one is what keeps the pictures inside the column in the meantime.

    The real one

    <compare-images-slider> is a separate package that does this properly. It wears the same role="separator" on the same kind of handle and does the opposite thing with it:

    <splitter-elemental> <compare-images-slider>
    What moves both panes: one gets the width the other gives up nothing. Both layers stay full size
    How three grid tracks, resized a clip-path on the layer in front
    What it is for a sidebar, an editor beside its preview before and after โ€” a retouch, a renovation, a map at two dates
    Where the content is side by side, and both readable at once stacked, and you reveal one by hiding the other
    What can go behind the seam pictures, because they are what can be pinned anything, because nothing is resized

    The tell is whether anything changes size. If the two things are the same thing in two states, you want the compare slider. If they are two different things sharing a width, you want the splitter.

    So build it out of the splitter when the two things are the same frame and you want no dependency at all. Reach for the real one when you want inertia, a double-tap to either extreme, a drag that starts anywhere, or anything but an image behind the seam.