Navigation

v0.0.4
Media

Compare

Two pictures of the same thing, and a handle that draws the line between them.

On this page

Compare lays one picture over another of the same thing and lets a reader move the line between them: before and after an edit, light and dark, a draft and its revision. The handle is a native <input type="range">, so it has the keys, the value and the name of a slider. Its thumb is a pearl grip on a fine line, and the picture on top is clipped at that line.

Compare

<figure class="compare max-w(520) mx-auto">  <img class="compare-before" src="/demo/moonstone.svg" alt="The landscape before grading: grey and flat" style="aspect-ratio: 16 / 10; filter: grayscale(1) contrast(.75)">  <img class="compare-after" src="/demo/moonstone.svg" alt="The landscape after grading">  <input type="range" class="compare-range" min="0" max="100" value="50" aria-label="Show more of the graded picture"></figure><script type="module">  import { attach } from '@master/ui/behaviors'  attach()</script>
Drag the grip, or focus it and use the arrow keys.

#The handle moves the picture

Where the browser has scroll-driven animations, the grip is a view timeline inside its input and the frame reads it: --compare-position follows the thumb with no script, and the clip and the line follow that property.

Elsewhere the compare behavior does the same job: it copies the input's value, as a percentage of its range, into --compare-position on the root, when it attaches and each time the input moves. Attach it in every browser: where the stylesheet already drives the property, the script changes nothing that is drawn.

app.js
import { compare } from '@master/ui/behaviors/compare.js'const detach = compare(document.querySelector('.compare'))

It takes the root, the element with the compare class, has no options and dispatches no event of its own: the input sends input and change as any range input does, and its value is where the line stands. Without the stylesheet's timeline and without the behavior, the pictures stay parted where --compare-position puts them, at the middle unless you set it.

The input is one grip wider than the frame, so the middle of the grip travels from one edge to the other and the line can reach both.

#Pictures

compare-before lies underneath and shows from the start to the line; compare-after lies on top and shows from the line to the end. Both fill the frame and crop to it, so give one of them the shape with a width and a height or an aspect-ratio, and use two pictures of the same framing.

The frame has a surface's corner and the inward edge every figure gets. Set --compare-radius to nest it in a card.

In a card

<div class="card w(280)">  <figure class="compare card-media" style="--compare-radius: calc(var(--radius-surface) - 4px)">    <img class="compare-before" src="/demo/portrait-1.svg" alt="The portrait as uploaded" style="aspect-ratio: 4 / 3; filter: saturate(.2) brightness(.92)">    <img class="compare-after" src="/demo/portrait-1.svg" alt="The portrait with the Warm preset">    <input type="range" class="compare-range" min="0" max="100" value="40" aria-label="Show more of the Warm preset">  </figure>  <div class="card-header">    <h3 class="card-title">Warm</h3>    <p class="card-description">Drag to compare with the original.</p>  </div></div><script type="module">  import { attach } from '@master/ui/behaviors'  attach()</script>

#Keyboard

The keys are the range input's own; the behavior adds none.

KeysAction
ArrowLeft, ArrowRight, ArrowUp, ArrowDownMove the line by a step, as the native range input does
Home, EndShow one picture whole

#Accessibility

  • Name the input for what moving it does: "Show more of the graded picture".
  • Give each picture an alt that says which one it is and how it differs. A reader who cannot see the two side by side needs the difference in words.
  • The handle is one tab stop. The arrow keys move the line by a step, Home and End show one picture whole.
  • The grip is 32px, and 44px on touch screens. The frame lets a finger scroll the page vertically across it.

#React, Svelte and Vue

The compare component attaches the behavior and passes the position in and out. The two pictures stay the page's own markup. Copy it into the project with npx master-ui add compare; React, Svelte and Vue says how the components are built and used.

ui/compare.tsx
'use client'import { useState, type ComponentProps } from 'react'import { compare } from '@master/ui/behaviors/compare.js'import { useBehavior } from './use-behavior'export interface CompareProps extends Omit<ComponentProps<'figure'>, 'defaultValue'> {  /** How much of the second picture shows, from 0 to 100. */  value?: number  defaultValue?: number  onValueChange?: (value: number) => void  /** The name of the handle: what moving it shows. */  label: string}/** Two pictures and the line between them. The children are the two images, `compare-before` and `compare-after`. */export function Compare({ value, defaultValue = 50, onValueChange, label, className, children, ...root }: CompareProps) {  const ref = useBehavior(compare, undefined, { value, defaultValue, onValueChange })  const [first] = useState(value ?? defaultValue)  return (    <figure className={className ? `compare ${className}` : 'compare'} ref={ref} {...root}>      {children}      <input type="range" className="compare-range" min={0} max={100} defaultValue={first} aria-label={label} />    </figure>  )}
ui/Compare.svelte
<script lang="ts" module>  import type { HTMLAttributes } from 'svelte/elements'  export interface CompareProps extends HTMLAttributes<HTMLElement> {    /** How much of the second picture shows, from 0 to 100. Bind it, or leave it out: it starts at 50. */    value?: number    onValueChange?: (value: number) => void    /** The name of the handle: what moving it shows. */    label: string  }</script><script lang="ts">  import { untrack } from 'svelte'  import { compare } from '@master/ui/behaviors/compare.js'  import { behavior } from './behavior.svelte.js'  let { value = $bindable(), onValueChange, label, class: className, children, ...root }: CompareProps = $props()  const first = untrack(() => value) ?? 50  const attach = behavior(compare, undefined, {    value: () => value,    change: (next) => { value = next; onValueChange?.(next) },  })</script><!--  @component  Two pictures and the line between them. The children are the two images, `compare-before` and `compare-after`.--><figure class={['compare', className]} {@attach attach} {...root}>  {@render children?.()}  <input type="range" class="compare-range" min="0" max="100" value={first} aria-label={label} /></figure>
ui/Compare.vue
<script setup lang="ts">import { useTemplateRef } from 'vue'import { compare } from '@master/ui/behaviors/compare.js'import { useBehavior } from './use-behavior'defineOptions({ inheritAttrs: false })defineProps<{  /** The name of the handle: what moving it shows. */  label: string}>()/** How much of the second picture shows, from 0 to 100: `v-model`. */const model = defineModel<number>({ default: 50 })/* Two pictures and the line between them. The default slot is the two images, `compare-before` and `compare-after`. */const first = model.valueconst root = useTemplateRef<HTMLElement>('root')useBehavior(root, compare, undefined, model)</script><template>  <figure class="compare" ref="root" v-bind="$attrs">    <slot />    <input type="range" class="compare-range" min="0" max="100" :value.attr="first" :aria-label="label">  </figure></template>

#Classes

ClassDescription
compareThe frame: it stacks the two pictures, draws the line and takes a surface's corner
compare-beforeThe picture underneath, shown from the start to the line
compare-afterThe picture on top, clipped at the line and shown from the line to the end
compare-rangeThe handle: a range input across the frame with no track and a grip for a thumb. Name it for what moving it does

#Custom properties

PropertyDefaultDescription
--compare-positionWhere the line stands, from the start, as a percentage. It is registered with an initial value of 50%; the stylesheet or the script sets it from the handle
--compare-radiusvar(--radius-surface)Corner of the frame; lower it to nest the frame in a card
--compare-grip2remSize of the grip; 2.75rem on a coarse pointer

#Source

@master/ui/components/compare.css
/* * Compare: two pictures of the same thing, one over the other, and a handle that draws the line between them. * The handle is a native range input: it has the keys, the value and the name of a slider. Its thumb is a * pearl grip on a fine line; the picture on top is clipped at the line. * Where scroll-driven animations exist the thumb's own place in the input sets --compare-position, so the * picture follows the handle with no script. Elsewhere a script copies the input's value into the same * property, and without either the pictures stay parted where --compare-position puts them. * The input is one grip wider than the frame, so the middle of the grip runs from one edge to the other. */@property --compare-position {  syntax: '<percentage>';  inherits: true;  initial-value: 50%;}@layer components {  .compare {    --compare-radius: var(--radius-surface);    --compare-grip: 2rem;    position: relative;    display: grid;    min-width: 0;    margin: 0;    border-radius: calc(var(--compare-radius) * var(--corner-scale));    corner-shape: var(--corner-shape);    overflow: clip;    background: var(--color-surface-chip);    user-select: none;    -webkit-user-select: none;    isolation: isolate;    /* The line between the two pictures: pearl, with a faint dark edge so it holds on a pale picture. */    &::before {      content: '';      position: absolute;      inset-block: 0;      inset-inline-start: var(--compare-position);      z-index: 1;      width: 1.5px;      translate: -50% 0;      background: var(--color-on-image);      box-shadow: 0 0 0 .5px var(--color-on-image-shade), 0 0 6px var(--color-on-image-shade);      pointer-events: none;    }    &:dir(rtl)::before {      translate: 50% 0;    }    /* The inward edge every picture gets, drawn over both of them. */    &::after {      content: '';      position: absolute;      inset: 0;      z-index: 2;      border-radius: inherit;      corner-shape: inherit;      box-shadow: inset 0 0 0 1px var(--color-line-image);      pointer-events: none;    }    @media (forced-colors: active) {      outline: 1px solid CanvasText;      outline-offset: -1px;      &::before {        background: CanvasText;      }    }  }  /* The two pictures share one cell and one size. */  .compare-before, .compare-after {    display: block;    grid-area: 1 / 1;    width: 100%;    height: 100%;    max-width: none;    margin: 0;    object-fit: cover;    pointer-events: none;  }  /* The one on top shows from the line to the end. */  .compare-after {    clip-path: inset(0 0 0 var(--compare-position));    &:dir(rtl) {      clip-path: inset(0 var(--compare-position) 0 0);    }  }  /* The handle: a range input across the middle of the frame, with no track and a grip for a thumb. */  .compare-range {    position: absolute;    inset-block-start: 50%;    inset-inline: calc(var(--compare-grip) / -2);    z-index: 3;    width: calc(100% + var(--compare-grip));    height: var(--compare-grip);    margin: 0;    padding: 0;    border: 0;    translate: 0 -50%;    background: transparent;    appearance: none;    cursor: ew-resize;    touch-action: pan-y;    -webkit-tap-highlight-color: transparent;    &:focus-visible {      outline: none;    }    &::-webkit-slider-runnable-track {      height: var(--compare-grip);      background: transparent;    }    /* The grip: the pearl thumb of a slider, larger, with a pair of chevrons to say which way it goes. */    &::-webkit-slider-thumb {      width: var(--compare-grip);      height: var(--compare-grip);      border: 0;      border-radius: 999px;      background:        url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none' stroke='%2334353a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m13 12-4 4 4 4M19 12l4 4-4 4'/%3E%3C/svg%3E") center / contain no-repeat,        linear-gradient(180deg, var(--color-thumb-high), var(--color-thumb-low));      box-shadow: 0 0 0 .5px var(--color-on-image-shade), 0 1px 2px -.5px var(--color-on-image-shade), 0 3px 6px -2px var(--color-on-image-shade), 0 8px 16px -8px var(--color-on-image-shade);      appearance: none;      transition: scale var(--duration-quick) var(--easing-glide);    }    &::-moz-range-track {      height: var(--compare-grip);      background: transparent;    }    &::-moz-range-thumb {      width: var(--compare-grip);      height: var(--compare-grip);      border: 0;      border-radius: 999px;      background:        url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none' stroke='%2334353a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m13 12-4 4 4 4M19 12l4 4-4 4'/%3E%3C/svg%3E") center / contain no-repeat,        linear-gradient(180deg, var(--color-thumb-high), var(--color-thumb-low));      box-shadow: 0 0 0 .5px var(--color-on-image-shade), 0 1px 2px -.5px var(--color-on-image-shade), 0 3px 6px -2px var(--color-on-image-shade), 0 8px 16px -8px var(--color-on-image-shade);    }    &:active::-webkit-slider-thumb {      scale: 1.06;    }    &:focus-visible::-webkit-slider-thumb {      outline: 2px solid var(--color-on-image);      outline-offset: 2px;      box-shadow: 0 0 0 2px var(--color-focus), 0 0 0 .5px var(--color-on-image-shade), 0 3px 6px -2px var(--color-on-image-shade);    }    &:focus-visible::-moz-range-thumb {      outline: 2px solid var(--color-on-image);      outline-offset: 2px;    }    @media (pointer: coarse) {      --compare-grip: 2.75rem;    }    @media (prefers-reduced-motion: reduce) {      &:active::-webkit-slider-thumb {        scale: none;      }    }    @media (forced-colors: active) {      &::-webkit-slider-thumb {        forced-color-adjust: none;        background: ButtonText;        box-shadow: none;      }    }  }  /* The handle moves the picture: the grip is a view timeline in its input and the frame reads it. */  @supports (view-timeline: --compare-grip inline) and (timeline-scope: --compare-grip) {    .compare {      timeline-scope: --compare-grip;      animation-name: compare-position;      animation-timing-function: linear;      animation-fill-mode: both;      animation-timeline: --compare-grip;      animation-range: contain;      &:dir(rtl) {        animation-direction: reverse;      }    }    .compare-range {      overflow: hidden;      &::-webkit-slider-thumb {        view-timeline: --compare-grip inline;      }    }  }}@keyframes compare-position {  from {    --compare-position: 100%;  }  to {    --compare-position: 0%;  }}