← каталог
Before/After Slider
Сравнение «до/после»: разделитель перетаскивается мышью, тачем или клавиатурой; верхний слой обрезается clip-path.
cssdragbasic0reduced-motion ✓
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| before? | ReactNode | — | |
| after? | ReactNode | — | |
| initial? | number | 50 | Стартовая позиция разделителя, % (формат «50»). |
| labels? | [string, string] | ["Before", "After"] |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/before-after-slider.jsonЧто нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useState, type CSSProperties, type ReactNode } from "react";
const fill: CSSProperties = { position: "absolute", inset: 0 };
/** Демо-«фотография» из градиентов (записи без внешних ассетов). mono — чёрно-белая версия «до». */
function Art({ mono }: { mono?: boolean }) {
return (
<div style={{ ...fill, filter: mono ? "grayscale(1) brightness(0.82)" : undefined }}>
<div
style={{
...fill,
background: "linear-gradient(160deg, #312e81 0%, #7c3aed 45%, #fb7185 75%, #fbbf24 100%)",
}}
/>
<div
style={{
position: "absolute",
left: "50%",
top: "56%",
width: 130,
height: 130,
transform: "translate(-50%, -50%)",
borderRadius: "50%",
background: "radial-gradient(circle, rgba(255,244,214,0.95), rgba(251,191,36,0.12) 70%)",
}}
/>
<div
style={{
position: "absolute",
inset: "auto 0 0 0",
height: "36%",
background: "#171422",
clipPath: "polygon(0 100%, 0 58%, 22% 18%, 45% 72%, 66% 6%, 100% 60%, 100% 100%)",
}}
/>
</div>
);
}
const badge = (side: CSSProperties): CSSProperties => ({
position: "absolute",
top: 10,
...side,
padding: "4px 10px",
borderRadius: 999,
fontSize: "0.68rem",
fontFamily: "ui-monospace, monospace",
letterSpacing: "0.06em",
textTransform: "uppercase",
background: "oklch(0.15 0.01 270 / 0.6)",
color: "oklch(0.95 0.01 265)",
backdropFilter: "blur(6px)",
pointerEvents: "none",
});
export interface BeforeAfterSliderProps {
before?: ReactNode;
after?: ReactNode;
/** Стартовая позиция разделителя, % (формат «50»). */
initial?: number;
labels?: [string, string];
}
/**
* Сравнение «до/после»: верхний слой обрезается clip-path по позиции разделителя.
* Управление — невидимый range-инпут на всю область: перетаскивание мышью/тачем и клавиатура (a11y).
*/
export function BeforeAfterSlider({
before,
after,
initial = 50,
labels = ["Before", "After"],
}: BeforeAfterSliderProps) {
const [pos, setPos] = useState(initial);
return (
<div
style={{
position: "relative",
width: "min(460px, 88vw)",
aspectRatio: "16 / 10",
borderRadius: 16,
overflow: "hidden",
border: "1px solid oklch(0.3 0.02 270)",
isolation: "isolate",
}}
>
<div style={fill}>{after ?? <Art />}</div>
<div style={{ ...fill, clipPath: `inset(0 ${100 - pos}% 0 0)` }}>{before ?? <Art mono />}</div>
<div
aria-hidden
style={{
position: "absolute",
top: 0,
bottom: 0,
left: `${pos}%`,
width: 2,
marginLeft: -1,
background: "oklch(0.98 0 0 / 0.9)",
boxShadow: "0 0 12px rgb(0 0 0 / 0.45)",
}}
>
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 34,
height: 34,
borderRadius: "50%",
background: "oklch(0.98 0 0)",
color: "oklch(0.2 0.02 270)",
display: "grid",
placeItems: "center",
fontSize: 15,
fontWeight: 700,
}}
>
⇄
</div>
</div>
<span style={badge({ left: 10 })}>{labels[0]}</span>
<span style={badge({ right: 10 })}>{labels[1]}</span>
<input
type="range"
min={0}
max={100}
step={0.1}
value={pos}
onChange={(e) => setPos(Number(e.target.value))}
aria-label="Позиция сравнения до/после"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
opacity: 0,
cursor: "ew-resize",
margin: 0,
}}
/>
</div>
);
}
/**
* Before/After Slider — vanilla JS, ноль зависимостей. Оживляет существующую разметку:
* верхний слой обрезается clip-path по позиции невидимого range-инпута (мышь/тач/клавиатура).
*
* <div id="ba" style="position:relative;aspect-ratio:16/10;overflow:hidden;border-radius:16px">
* <img data-after src="after.jpg" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover">
* <img data-before src="before.jpg" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover">
* </div>
* <script type="module">
* import { initBeforeAfter } from './before-after-slider.vanilla.js';
* const destroy = initBeforeAfter(document.querySelector('#ba'), { initial: 50 });
* </script>
*
* @param {HTMLElement} root — контейнер с [data-before] (верхний слой) и [data-after]
* @param {{ initial?: number }} [options] — стартовая позиция, % (формат «50»)
* @returns {() => void} destroy
*/
export function initBeforeAfter(root, { initial = 50 } = {}) {
const before = root.querySelector("[data-before]");
if (!before) return () => {};
// разделитель с ручкой
const divider = document.createElement("div");
divider.setAttribute("aria-hidden", "true");
divider.style.cssText = `position:absolute;top:0;bottom:0;width:2px;margin-left:-1px;
background:oklch(0.98 0 0 / 0.9);box-shadow:0 0 12px rgb(0 0 0 / 0.45);pointer-events:none;`;
const knob = document.createElement("div");
knob.textContent = "⇄";
knob.style.cssText = `position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:34px;height:34px;
border-radius:50%;background:oklch(0.98 0 0);color:oklch(0.2 0.02 270);display:grid;place-items:center;
font-size:15px;font-weight:700;`;
divider.appendChild(knob);
root.appendChild(divider);
// невидимый range на всю область — перетаскивание и клавиатура бесплатно
const input = document.createElement("input");
input.type = "range";
input.min = "0";
input.max = "100";
input.step = "0.1";
input.value = String(initial);
input.setAttribute("aria-label", "Позиция сравнения до/после");
input.style.cssText = "position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:ew-resize;margin:0;";
root.appendChild(input);
const apply = () => {
const p = Number(input.value);
before.style.clipPath = `inset(0 ${100 - p}% 0 0)`;
divider.style.left = `${p}%`;
};
input.addEventListener("input", apply);
apply();
return () => {
input.removeEventListener("input", apply);
input.remove();
divider.remove();
before.style.clipPath = "";
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0