Motiva
Мои коллекции
← каталог

Before/After Slider

Сравнение «до/после»: разделитель перетаскивается мышью, тачем или клавиатурой; верхний слой обрезается clip-path.

cssdragbasic0reduced-motion ✓

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
before?ReactNode
after?ReactNode
initial?number50Стартовая позиция разделителя, % (формат «50»).
labels?[string, string]["Before", "After"]

Установка

npx shadcn@latest add https://motiva.pages.dev/r/before-after-slider.json
Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0