Before / After
Two points of view, one surface. Pull a fine line between what was and what could be.
'use client'
import { useId, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import './before-after.css'
export interface BeforeAfterProps {
/** Decorative artwork without links or controls. */
before: ReactNode
/** Decorative artwork without links or controls. */
after: ReactNode
beforeLabel?: string
afterLabel?: string
/** Initial revealed percentage, clamped from 0 to 100. */
defaultValue?: number
onValueChange?: (value: number) => void
className?: string
}
function bounded(value: number) {
return Number.isFinite(value) ? Math.max(0, Math.min(100, Math.round(value))) : 50
}
/** A comparison surface with a native, keyboard-operable reveal slider. */
export function BeforeAfter({ before, after, beforeLabel = 'Before', afterLabel = 'After', defaultValue = 50, onValueChange, className = '' }: BeforeAfterProps) {
const id = useId()
const [value, setValue] = useState(() => bounded(defaultValue))
return (
<section className={`ac-before-after ${className}`.trim()} aria-label={`${beforeLabel} and ${afterLabel} comparison`} style={{ '--ac-compare-position': `${value}%` } as CSSProperties}>
<div className="ac-compare-stage">
<div className="ac-compare-face ac-compare-after" aria-hidden="true" ref={(element) => { element?.setAttribute('inert', '') }}>{after}</div>
<div className="ac-compare-face ac-compare-before" aria-hidden="true" ref={(element) => { element?.setAttribute('inert', '') }}>{before}</div>
<div className="ac-compare-labels" aria-hidden="true"><span>{beforeLabel}</span><span>{afterLabel}</span></div>
<div className="ac-compare-divider" aria-hidden="true">
<span className="ac-compare-handle"><svg width="22" height="18" viewBox="0 0 22 18" fill="none"><path d="m7 5-4 4 4 4m8-8 4 4-4 4M3 9h5m6 0h5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg></span>
</div>
<label className="ac-compare-sr-only" htmlFor={id}>Reveal {beforeLabel} and {afterLabel}</label>
<input
id={id}
className="ac-compare-range"
type="range"
min={0}
max={100}
step={1}
value={value}
aria-describedby={`${id}-hint`}
aria-valuetext={`${value}% ${beforeLabel}, ${100 - value}% ${afterLabel}`}
onChange={(event) => {
const next = bounded(Number(event.currentTarget.value))
setValue(next)
onValueChange?.(next)
}}
/>
</div>
<div className="ac-compare-footer">
<p id={`${id}-hint`}>Drag the handle. Find the difference.</p>
<output htmlFor={id} aria-live="off">{value}<span aria-hidden="true"> / </span><span className="ac-compare-sr-only"> out of </span>100</output>
</div>
</section>
)
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/before-after.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { BeforeAfter } from '@/components/anticraft/BeforeAfter';
export function Example() {
return (
<BeforeAfter
beforeLabel="First thought"
afterLabel="Fresh perspective"
defaultValue={50}
before={<div style={{ display: 'grid', placeItems: 'center', background: '#c6c5b9', color: '#363a33', fontSize: 58 }}>Aa</div>}
after={<div style={{ display: 'grid', placeItems: 'center', background: '#e9a577', color: '#59352a', fontFamily: 'Georgia, serif', fontStyle: 'italic', fontSize: 72 }}>Aa</div>}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
before | ReactNode | Required | Decorative artwork for the left reveal layer. Images fill the surface using object-fit: cover. Keep faces free of controls and provide meaningful descriptions in the labels or surrounding text. |
after | ReactNode | Required | Decorative artwork beneath the reveal. Both faces stay full size as the dividing line moves; their content is hidden from assistive technology and made inert. |
beforeLabel | string | 'Before' | The visible label for the left layer. It is also used in the slider’s accessible name and value. |
afterLabel | string | 'After' | The visible label for the right layer. It is also used in the slider’s accessible name and value. |
defaultValue | number | 50 | The initial percentage of the before layer revealed, rounded and clamped from 0 to 100. Non-finite values fall back to 50. |
onValueChange | (value: number) => void | undefined | Called when the native slider changes the reveal percentage. Use a client component when providing this callback. |
className | string | '' | Additional classes. Set --ac-compare-height to change the default 300px height; paper, ink, and focus tokens customize the frame. |
Motion & accessibility
Drag the handle along the middle of the artwork or focus the native range and use arrow keys, Home, or End. The 44px horizontal interaction strip preserves vertical touch scrolling. The divider follows the value directly, with no automatic animation or easing. The slider exposes both labels and the current percentage; the visual output does not announce every drag movement.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.