Before / After
A before/after comparison with a draggable divider; arrows and Page keys move it too.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/before-after.json'use client'
import { useRef, useState } from 'react'
import './before-after.css'
export interface BeforeAfterProps {
/** Content for the after layer (usually an <img>). */
after: React.ReactNode
/** Content for the before layer. Defaults to the after content, desaturated. */
before?: React.ReactNode
value?: number
defaultValue?: number
onValueChange?: (v: number) => void
label?: string
fill?: 'dark' | 'light'
className?: string
}
/** A before/after comparison with a draggable divider. role="slider"; arrows move 1%, Page keys 10%. */
export function BeforeAfter({ after, before, value, defaultValue = 50, onValueChange, label = 'Comparison', fill = 'dark', className }: BeforeAfterProps) {
const [inner, setInner] = useState(defaultValue)
const v = value ?? inner
const box = useRef<HTMLDivElement>(null)
const dragging = useRef(false)
const set = (n: number) => {
const c = Math.min(100, Math.max(0, n))
if (value === undefined) setInner(c)
onValueChange?.(c)
}
const fromX = (x: number) => {
const r = box.current!.getBoundingClientRect()
set(((x - r.left) / r.width) * 100)
}
return (
<div
ref={box}
className={['sd-ba', className].filter(Boolean).join(' ')}
data-fill={fill}
style={{ '--ba': `${v}%` } as React.CSSProperties}
onPointerDown={(e) => {
dragging.current = true
e.currentTarget.setPointerCapture(e.pointerId)
fromX(e.clientX)
}}
onPointerMove={(e) => dragging.current && fromX(e.clientX)}
onPointerUp={() => (dragging.current = false)}
>
<div className="sd-ba__layer">{after}</div>
<div className="sd-ba__layer sd-ba__before">{before ?? <div className="sd-ba__desat">{after}</div>}</div>
<span className="sd-ba__tag" data-side="l" aria-hidden>Before</span>
<span className="sd-ba__tag" data-side="r" aria-hidden>After</span>
<div
className="sd-ba__handle"
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(v)}
aria-valuetext={`${Math.round(v)}% before`}
onKeyDown={(e) => {
const m: Record<string, number> = { ArrowLeft: v - 1, ArrowRight: v + 1, PageDown: v - 10, PageUp: v + 10, Home: 0, End: 100 }
if (e.key in m) (e.preventDefault(), set(m[e.key]))
}}
>
<span aria-hidden>
<svg viewBox="0 0 20 20"><path d="M7.5 6 3.5 10l4 4M12.5 6l4 4-4 4" /></svg>
</span>
</div>
</div>
)
}.sd-ba { --ring: #fff; position: relative; width: min(100%, 320px); aspect-ratio: 4 / 3; overflow: hidden; border-radius: 22px; touch-action: pan-y; user-select: none; cursor: ew-resize; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.1); }
.sd-ba[data-fill='light'] { --ring: #111; box-shadow: 0 10px 30px -14px rgb(0 0 0 / 0.4); }
.sd-ba__layer { position: absolute; inset: 0; }
.sd-ba__layer > *, .sd-ba__layer img, .sd-ba__layer svg { width: 100%; height: 100%; display: block; object-fit: cover; }
.sd-ba__before { clip-path: inset(0 calc(100% - var(--ba)) 0 0); }
.sd-ba__desat { filter: grayscale(0.85) contrast(0.8) brightness(0.9); }
.sd-ba__tag { position: absolute; top: 10px; height: 24px; display: inline-flex; align-items: center; padding: 0 9px; border-radius: 999px; background: rgb(0 0 0 / 0.5); color: #fff; font-size: 12px; font-weight: 500; backdrop-filter: blur(6px); }
.sd-ba__tag[data-side='l'] { left: 10px; } .sd-ba__tag[data-side='r'] { right: 10px; }
.sd-ba__handle { position: absolute; top: 0; bottom: 0; left: var(--ba); width: 2px; margin-left: -1px; background: var(--ring); outline: none; }
.sd-ba__handle > span { position: absolute; top: 50%; left: 50%; display: grid; place-items: center; width: 40px; height: 40px; translate: -50% -50%; border-radius: 99px; background: var(--ring); color: color-mix(in srgb, var(--ring) 10%, #777); box-shadow: 0 6px 18px rgb(0 0 0 / 0.35); transition: scale 250ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-ba[data-fill='light'] .sd-ba__handle > span { color: #fff; }
.sd-ba:active .sd-ba__handle > span { scale: 0.9; }
.sd-ba__handle:focus-visible > span { box-shadow: 0 0 0 3px rgb(0 0 0 / 0.6), 0 0 0 5px var(--ring); }
.sd-ba__handle svg { width: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
@media (prefers-reduced-motion: reduce) { .sd-ba * { transition: none !important; } }Overview
Drag anywhere to move the divider. The before layer is clipped, not resized, so both images stay aligned.
Pass two images, or one and let the component desaturate it for the before side.
When to use
- Photo edits, redesigns, renovations, model outputs.
When not to use
- Images that are not aligned: the comparison only works pixel to pixel.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/before-after.json$
npx shadcn@latest add https://sundaeui.dev/r/before-after.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/before-after.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/before-after.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy before-after.tsx and before-after.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { BeforeAfter } from '@/components/sundae/before-after'
<BeforeAfter before={<img src="/raw.jpg" alt="" />} after={<img src="/graded.jpg" alt="Graded photo" />} />Props
| Prop | Type | Default |
|---|---|---|
after*After layer. | ReactNode | — |
beforeBefore layer. | ReactNode | after, desaturated |
value / defaultValueDivider position, %. | number | 50 |
labelSlider name. | string | 'Comparison' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The handle is role="slider" with a percentage value.
- Labels Before and After are visible.
- Reduced motion removes the handle scale.
Customization
- Every colour is a CSS custom property on the root class; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Move 1%
- Page Up / Down
- Move 10%
- Home / End
- All before / all after
FAQ
- Is Before / After free?
- Yes. Before / After is free for personal and commercial projects, with full source on this page.
- How do I install Before / After?
- Run npx shadcn@latest add https://sundaeui.dev/r/before-after.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Before / After support keyboard and screen readers?
- The handle is role="slider" with a percentage value. Labels Before and After are visible.
- Does Before / After respect reduced motion?
- Reduced motion removes the handle scale.
More like this
All 3d →- Pro
Photo Adjust
3D · Refined - Pro
Holo Ticket
3D · Futuristic - Pro
Card Swap
3D · Expressive - Pro
Glass Control Center
Components · Refined - Pro
Tick Gauge
Data · Refined Avatar Upload
Forms · Refined