Before/After Slider
Сравнение «до/после»: разделитель перетаскивается мышью, тачем или клавиатурой; верхний слой обрезается clip-path.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| before? | ReactNode | — | |
| after? | ReactNode | — | |
| initial? | number | 50 | Стартовая позиция разделителя, %. |
| labels? | [string, string] | ["Before", "After"] |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/before-after-slider.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.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;
/** Стартовая позиция разделителя, %. */
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] — стартовая позиция, %
* @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 = "";
};
}