├── .editorconfig ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ ├── publish-preview.yml │ └── ssr.yml ├── .gitignore ├── .husky ├── post-merge └── pre-commit ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── docs ├── CONTRIBUTING.md ├── example │ ├── .eslintrc │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── package.json │ ├── public │ │ └── vite.svg │ ├── src │ │ ├── App.tsx │ │ ├── index.css │ │ ├── main.tsx │ │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── ssr-tests │ ├── README.md │ ├── package.json │ └── ssr.test.mjs └── storybook │ ├── .eslintrc │ ├── .storybook │ ├── hack-coverage.js │ ├── main.ts │ ├── manager-head.html │ ├── manager.ts │ ├── preview-head.html │ ├── preview.ts │ └── theme.ts │ ├── content │ ├── 00-introduction.stories.mdx │ ├── 01-api.stories.mdx │ ├── 01-images.stories.mdx │ ├── 02-bounds-padding.stories.mdx │ ├── 03-browsing-context.stories.mdx │ ├── 03-change-position-on-hover.stories.mdx │ ├── 03-clip.stories.mdx │ ├── 04-disabled.stories.mdx │ ├── 05-handles.stories.mdx │ ├── 06-keyboard-increment.stories.mdx │ ├── 07-only-handle-draggable.stories.mdx │ ├── 08-transition.stories.mdx │ ├── 09-use-react-compare-slider-ref.stories.mdx │ ├── 99-real-world-examples.stories.mdx │ └── stories │ │ ├── 00-demos │ │ ├── 00-index.stories.tsx │ │ └── 02-edge-cases.stories.tsx │ │ ├── 01-recipes │ │ ├── 00-index.stories.tsx │ │ └── 01-google-maps.stories.tsx │ │ ├── 02-handles │ │ ├── 00-react-compare-slider-handle.stories.tsx │ │ └── 01-custom.stories.tsx │ │ ├── 99-tests │ │ ├── clip.test.stories.tsx │ │ ├── default.test.stories.tsx │ │ ├── disabled.test.stories.tsx │ │ ├── keyboard-interactions.test.stories.tsx │ │ ├── pointer-interactions.stories.tsx │ │ ├── position.test.stories.tsx │ │ ├── react-compare-slider-handle.test.stories.tsx │ │ ├── react-compare-slider-image.test.stories.tsx │ │ ├── right-to-left.test.stories.tsx │ │ ├── scroll.test.stories.tsx │ │ ├── test-utils.test.tsx │ │ ├── transition.test.stories.tsx │ │ ├── use-react-compare-slider-ref.test.stories.tsx │ │ └── zero-bounds.test.stories.tsx │ │ └── config.ts │ ├── package.json │ └── tsconfig.json ├── lib ├── .release-it.json ├── package.json ├── src │ ├── Container.tsx │ ├── ReactCompareSlider.tsx │ ├── ReactCompareSliderHandle.tsx │ ├── ReactCompareSliderImage.tsx │ ├── index.ts │ ├── types.ts │ ├── useReactCompareSliderRef.ts │ └── utils.ts ├── tsconfig.json └── tsup.config.ts ├── package.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | indent_size = 2 10 | insert_final_newline = true 11 | max_line_length = 90 12 | trim_trailing_whitespace = true 13 | 14 | [*.{md,mdx}] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "jsx": true 8 | } 9 | }, 10 | "plugins": ["@typescript-eslint", "react"], 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:react/recommended", 14 | "plugin:react-hooks/recommended", 15 | "plugin:@typescript-eslint/eslint-recommended", 16 | "plugin:@typescript-eslint/recommended" 17 | ], 18 | "env": { 19 | "browser": true, 20 | "es6": true 21 | }, 22 | "settings": { 23 | "react": { "version": "detect" } 24 | }, 25 | "rules": { 26 | "linebreak-style": ["error", "unix"], 27 | "lines-around-comment": 0, 28 | "no-constant-binary-expression": "error", 29 | "no-confusing-arrow": 0, 30 | "no-console": "error", 31 | "no-unused-vars": 0, 32 | "require-await": 0, 33 | "react/jsx-indent": 0, 34 | "react/jsx-indent-props": 0, 35 | "react/prop-types": 0, 36 | "@typescript-eslint/consistent-type-imports": "warn", 37 | "@typescript-eslint/explicit-function-return-type": "warn", 38 | "@typescript-eslint/no-non-null-assertion": "warn", 39 | "@typescript-eslint/no-shadow": "warn", 40 | "@typescript-eslint/no-unused-vars": [ 41 | "error", 42 | { 43 | "args": "all", 44 | "argsIgnorePattern": "^__", 45 | "varsIgnorePattern": "^__" 46 | } 47 | ] 48 | }, 49 | "ignorePatterns": [ 50 | "*.log", 51 | "*.md", 52 | "*.mdx", 53 | "node_modules/", 54 | "dist/", 55 | "coverage/", 56 | ".nyc_output/", 57 | ".pnpm-store/", 58 | "pnpm-lock.yaml", 59 | "storybook-static/", 60 | ".next/", 61 | "docs/storybook/src/" 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [nerdyman] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | types: 8 | - opened 9 | - synchronize 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | name: Build, lint and test 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: 🛒 Checkout repo 19 | uses: actions/checkout@v3 20 | 21 | - uses: pnpm/action-setup@v2 22 | 23 | - name: ⚒️ Use Node.js 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version-file: '.nvmrc' 27 | cache: 'pnpm' 28 | 29 | - name: 📦 Install Dependencies 30 | run: npm run bootstrap 31 | 32 | - name: 🚦 Lint 33 | run: pnpm run lint 34 | 35 | - name: 🔨 Build 36 | run: pnpm run --filter react-compare-slider build 37 | 38 | - name: 🧑‍🏫 Build Example Project 39 | run: pnpm run --filter @this/example build 40 | 41 | - name: 🕵️ Check Package Configuration 42 | run: pnpm run --filter react-compare-slider check:package 43 | 44 | - name: 🧪 Test 45 | run: | 46 | pnpm dlx playwright@1.41.1 install --with-deps chromium 47 | pnpm run test:ci 48 | 49 | - name: Upload Code Climate Test Coverage 50 | uses: paambaati/codeclimate-action@v5.0.0 51 | env: 52 | CC_TEST_REPORTER_ID: '${{ secrets.CC_TEST_REPORTER_ID }}' 53 | with: 54 | debug: true 55 | coverageLocations: | 56 | ${{github.workspace}}/coverage/ssr-tests/*.info:lcov 57 | ${{github.workspace}}/coverage/storybook/*.info:lcov 58 | 59 | - uses: preactjs/compressed-size-action@v2 60 | with: 61 | repo-token: '${{ secrets.GITHUB_TOKEN }}' 62 | install-script: 'pnpm install --frozen-lockfile --filter react-compare-slider' 63 | build-script: 'build' 64 | cwd: lib 65 | -------------------------------------------------------------------------------- /.github/workflows/publish-preview.yml: -------------------------------------------------------------------------------- 1 | name: Publish Preview Release 2 | on: 3 | pull_request: 4 | types: [synchronize] 5 | jobs: 6 | approved: 7 | if: contains(join(github.event.pull_request.labels.*.name, ','), 'publish-preview') 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: 🛒 Checkout repo 12 | uses: actions/checkout@v3 13 | 14 | - uses: pnpm/action-setup@v2 15 | 16 | - name: ⚒️ Use Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version-file: '.nvmrc' 20 | cache: 'pnpm' 21 | 22 | - name: 📦 Install Dependencies 23 | run: | 24 | corepack enable 25 | pnpm install --frozen-lockfile --filter react-compare-slider 26 | 27 | - name: 🔨 Build 28 | run: pnpm run --filter react-compare-slider build 29 | 30 | - name: 🚀 Publish Preview Release 31 | run: pnpx pkg-pr-new publish './lib' --template='./docs/example' 32 | -------------------------------------------------------------------------------- /.github/workflows/ssr.yml: -------------------------------------------------------------------------------- 1 | name: SSR 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | types: 8 | - opened 9 | - synchronize 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | name: Run Legacy Node.js Tests 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: 🛒 Checkout repo 19 | uses: actions/checkout@v3 20 | 21 | - uses: pnpm/action-setup@v2 22 | 23 | - name: ⚒️ Use Node.js 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: 18 27 | cache: 'pnpm' 28 | 29 | - name: 📦 Install Dependencies 30 | run: | 31 | corepack enable 32 | pnpm install --frozen-lockfile --filter . --filter @this/ssr-tests --filter react-compare-slider 33 | 34 | - name: 🔨 Build 35 | run: pnpm run --filter react-compare-slider build 36 | 37 | - name: 🧪 Test 38 | run: pnpm --filter @this/ssr-tests run test-legacy 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | *.tgz 6 | dist 7 | coverage 8 | .env 9 | .pnpm-store/ 10 | storybook-static 11 | docs/storybook/src 12 | lib/README.md 13 | lib/LICENSE 14 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | pnpm i --frozen-lockfile 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | public-hoist-pattern[]=@types* 3 | public-hoist-pattern[]=@storybook* 4 | public-hoist-pattern[]=@testing-library* 5 | public-hoist-pattern[]=jest* 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/iron 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | *.mdx 3 | *.log 4 | node_modules/ 5 | dist/ 6 | coverage/ 7 | .nyc_output/ 8 | .pnpm-store/ 9 | pnpm-lock.yaml 10 | storybook-static/ 11 | .next/ 12 | docs/storybook/src 13 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@ianvs/prettier-plugin-sort-imports').PrettierConfig} */ 2 | module.exports = { 3 | plugins: [require.resolve('@ianvs/prettier-plugin-sort-imports')], 4 | printWidth: 100, 5 | semi: true, 6 | singleQuote: true, 7 | trailingComma: 'all', 8 | endOfLine: 'lf', 9 | useTabs: false, 10 | 11 | importOrder: ['^~/(.*)$', '^[./]'], 12 | importOrderBuiltinModulesToTop: true, 13 | importOrderCombineTypeAndValueImports: false, 14 | importOrderSeparation: true, 15 | importOrderSortSpecifiers: true, 16 | }; 17 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "EditorConfig.EditorConfig", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "editor.rulers": [100], 6 | "editor.formatOnSave": true, 7 | "search.exclude": { 8 | "**/node_modules": true, 9 | "**/bower_components": true, 10 | "**/*.code-search": true, 11 | ".env": false 12 | }, 13 | 14 | "jest.autoRun": "off", 15 | "jest.showCoverageOnLoad": false, 16 | 17 | "[javascript]": { 18 | "editor.defaultFormatter": "esbenp.prettier-vscode" 19 | }, 20 | "[javascriptreact]": { 21 | "editor.defaultFormatter": "esbenp.prettier-vscode" 22 | }, 23 | "[jsonc]": { 24 | "editor.defaultFormatter": "esbenp.prettier-vscode" 25 | }, 26 | "[typescript]": { 27 | "editor.defaultFormatter": "esbenp.prettier-vscode" 28 | }, 29 | "[typescriptreact]": { 30 | "editor.defaultFormatter": "esbenp.prettier-vscode" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 nerdyman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

React Compare Slider

3 |

Compare two components side-by-side or top-to-toe.

4 | 5 | [![Example](https://raw.githubusercontent.com/nerdyman/stuff/main/libs/react-compare-slider/docs/hero.gif)](https://codesandbox.io/p/sandbox/github/nerdyman/react-compare-slider/tree/main/docs/example?file=/src/App.tsx:1,1) 6 | 7 | License MIT 8 | npm version 9 | Bundle size 10 |
11 | GitHub CI status 12 | Coverage 13 | Demos 14 | 15 |
16 | 17 | > [!IMPORTANT] 18 | > This readme is for the [v4 release](https://github.com/nerdyman/react-compare-slider/releases) which is currently in beta (`react-compare-slider@beta`). 19 | > 20 | > See [Version 3](https://github.com/nerdyman/react-compare-slider/tree/v3.1.0) for the latest stable release (`react-compare-slider`). 21 | 22 | --- 23 | 24 | ## Features 25 | 26 | - Supports responsive images and any other React components (`picture`, `video`, `canvas`, `iframe` etc.) 27 | - Supports landscape and portrait orientations 28 | - Accessible – screen reader and keyboard support out of the box 29 | - Simple API 30 | - Unopinionated & fully customizable – optionally use your own components and styles 31 | - Responsive & fluid with intrinsic sizing 32 | - Teeny-tiny, zero dependencies 33 | - Type safe 34 | 35 | ## Demos 36 | 37 | - Storybook: [docs](https://react-compare-slider.vercel.app/?path=/docs/docs-introduction--docs), [demos](https://react-compare-slider.vercel.app/?path=/story/demos), [custom recipes](https://react-compare-slider.vercel.app/?path=/story/recipes), [custom handles](https://react-compare-slider.vercel.app/?path=/story/handles), [`useReactCompareSliderRef`](https://react-compare-slider.vercel.app/?path=/docs/docs-usereactcomparesliderref--docs) 38 | - CodeSandbox: [basic demo](https://codesandbox.io/p/sandbox/github/nerdyman/react-compare-slider/tree/main/docs/example?file=/src/App.tsx:1,1) 39 | - [Local example](./docs/example) 40 | 41 | ## Usage 42 | 43 | ### Install 44 | 45 | ```sh 46 | npm install react-compare-slider 47 | # or 48 | yarn add react-compare-slider 49 | # or 50 | pnpm add react-compare-slider 51 | ``` 52 | 53 | ### Basic Image Usage 54 | 55 | You _may_ use `ReactCompareSliderImage` to render images or use your own custom 56 | components. 57 | 58 | ```jsx 59 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 60 | 61 | export const Example = () => { 62 | return ( 63 | } 65 | itemTwo={} 66 | /> 67 | ); 68 | }; 69 | ``` 70 | 71 | ## Props 72 | 73 | | Prop | Type | Required | Default | Description | 74 | | ---- | ---- | :------: | ------- | ----------- | 75 | | [`boundsPadding`](https://react-compare-slider.vercel.app/?path=/story/demos--bounds-padding) | `number` | | `0` | Padding to limit the slideable bounds in pixels on the X-axis (landscape) or Y-axis (portrait). 76 | | [`browsingContext`](https://react-compare-slider.vercel.app/?path=/story/demos--browsing-context) | `globalThis` | | `globalThis` | Context to bind events to (useful for iframes). 77 | | [`clip`](https://react-compare-slider.vercel.app/?path=/docs/docs-clip--docs) | `` both\|itemOne\|itemTwo `` | | `both` | Whether to clip `itemOne`, `itemTwo` or `both` items. 78 | | [`changePositionOnHover`](https://react-compare-slider.vercel.app/?path=/story/demos--change-position-on-hover) | `boolean` | | `false` | Whether the slider should follow the pointer on hover. 79 | | [`disabled`](https://react-compare-slider.vercel.app/?path=/story/demos--disabled) | `boolean` | | `false` | Whether to disable slider movement (items are still interactable). 80 | | [`handle`](https://react-compare-slider.vercel.app/?path=/story/demos--handle) | `ReactNode` | | `undefined` | Custom handle component. 81 | | `itemOne` | `ReactNode` | ✓ | `undefined` | First component to show in slider. 82 | | `itemTwo` | `ReactNode` | ✓ | `undefined` | Second component to show in slider. 83 | | [`keyboardIncrement`](https://react-compare-slider.vercel.app/?path=/story/demos--keyboard-increment) | `` number\|`${number}%` `` | | `5%` | Percentage or pixel amount to move when the slider handle is focused and keyboard arrow is pressed. 84 | | [`onlyHandleDraggable`](https://react-compare-slider.vercel.app/?path=/story/demos--only-handle-draggable) | `boolean` | | `false` | Whether to only change position when handle is interacted with (useful for touch devices). 85 | | [`onPositionChange`](https://react-compare-slider.vercel.app/?path=/story/demos--on-position-change) | `(position: number) => void` | | `undefined` | Callback on position change, returns current position percentage as argument. 86 | | [`portrait`](https://react-compare-slider.vercel.app/?path=/story/demos--portrait) | `boolean` | | `false` | Whether to use portrait orientation. 87 | | [`position`](https://react-compare-slider.vercel.app/?path=/story/demos--position) | `number` | | `50` | Initial percentage position of divide (`0-100`). 88 | | [`transition`](https://react-compare-slider.vercel.app/?path=/story/demos--transition) | `string` | | `undefined` | Shorthand CSS `transition` property to apply to handle movement. E.g. `.5s ease-in-out` 89 | 90 | [API docs](https://react-compare-slider.vercel.app/?path=/docs/docs-api--docs) for more information. 91 | 92 |
93 | 94 | ## Real World Examples 95 | 96 | Checkout out the [Real World Examples page](https://react-compare-slider.vercel.app/?path=/docs/docs-real-world-examples--docs). 97 | 98 | ## Requirements 99 | 100 | - React 16.8+ 101 | - The latest two versions of each major browser are officially supported (at time of publishing) 102 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for contributing! 4 | 5 | ## Getting Started 6 | 7 | This is a pnpm monorepo, consisting of the main package in [`lib`](../lib/) and 8 | [`storybook`](./storybook/) and [`example`](./example/) packages in the `docs` directory. Don't 9 | worry if you haven't used pnpm or monorepos before, the commands below will set everything up for you. 10 | 11 | If you run Windows please use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). 12 | 13 | Ensure you're using the Node version specified in [.nvmrc](../.nvmrc) and run the following to 14 | bootstrap the project: 15 | 16 | ```sh 17 | npm run bootstrap 18 | # Also useful but not required, install shell auto completion for pnpm. 19 | pnpm install-completion 20 | ``` 21 | 22 | To start the library in watch mode, run the following command: 23 | 24 | ```sh 25 | # Run the library only, note this must run before starting Storybook. 26 | pnpm run --filter react-compare-slider dev 27 | # You can also run the scripts from the directory itself if you prefer. 28 | cd lib 29 | pnpm run dev 30 | 31 | # Run Storybook only. 32 | pnpm run --filter @this/storybook dev 33 | # You can also run the scripts from the directory itself if you prefer. 34 | cd docs/storybook 35 | pnpm run dev 36 | 37 | # Run example only. 38 | pnpm run --filter @this/example dev 39 | # You can also run the scripts from the directory itself if you prefer. 40 | cd docs/example 41 | pnpm run dev 42 | ``` 43 | 44 | ## Testing 45 | 46 | If you already have Storybook running, use the following command: 47 | 48 | ```sh 49 | pnpm run test 50 | ``` 51 | 52 | If you _don't_ have Storybook running, use the following command: 53 | 54 | ```sh 55 | pnpm run test:ci 56 | ``` 57 | 58 | ## Standards 59 | 60 | - Commits use the [Conventional Commits](https://conventionalcommits.org/) standard 61 | - pnpm to manage dependencies 62 | - Use a tool (e.g. nvm or fnm) to use the correct Node.js version 63 | - Prettier & EditorConfig for code style 64 | - ESLint for quality 65 | - Husky for Git hooks 66 | 67 | ## VS Code 68 | 69 | If you're using VS Code please make sure you install the [recommended extensions](../.vscode/extensions.json). 70 | -------------------------------------------------------------------------------- /docs/example/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc", 3 | "rules": { 4 | "no-console": 0, 5 | "@typescript-eslint/explicit-function-return-type": 0, 6 | "react/react-in-jsx-scope": 0 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/example/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /docs/example/README.md: -------------------------------------------------------------------------------- 1 | # React Compare Slider Demo 2 | 3 | - 🧑‍💻 [CodeSandbox Demo](https://codesandbox.io/p/sandbox/github/nerdyman/react-compare-slider/tree/main/docs/example?file=/src/App.tsx:1,1) 4 | - 🐙 [GitHub Repo](https://github.com/nerdyman/react-compare-slider) 5 | 6 | --- 7 | 8 | ## React + TypeScript + Vite 9 | 10 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 11 | 12 | Currently, two official plugins are available: 13 | 14 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 15 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 16 | 17 | ### Expanding the ESLint configuration 18 | 19 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 20 | 21 | - Configure the top-level `parserOptions` property like this: 22 | 23 | ```js 24 | parserOptions: { 25 | ecmaVersion: 'latest', 26 | sourceType: 'module', 27 | project: ['./tsconfig.json', './tsconfig.node.json'], 28 | tsconfigRootDir: __dirname, 29 | }, 30 | ``` 31 | 32 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 33 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 34 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 35 | -------------------------------------------------------------------------------- /docs/example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Compare Slider Example 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-compare-slider-example", 3 | "license": "MIT", 4 | "version": "0.0.0", 5 | "private": true, 6 | "type": "module", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/nerdyman/react-compare-slider.git", 10 | "directory": "docs/example" 11 | }, 12 | "scripts": { 13 | "dev": "vite", 14 | "build": "tsc && vite build", 15 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 16 | "preview": "vite preview" 17 | }, 18 | "dependencies": { 19 | "react": "^18.2.0", 20 | "react-compare-slider": "latest", 21 | "react-dom": "^18.2.0" 22 | }, 23 | "devDependencies": { 24 | "@types/react": "^18.2.55", 25 | "@types/react-dom": "^18.2.19", 26 | "@typescript-eslint/eslint-plugin": "^6.21.0", 27 | "@typescript-eslint/parser": "^6.21.0", 28 | "@vitejs/plugin-react": "^4.2.1", 29 | "eslint": "^8.56.0", 30 | "eslint-plugin-react-hooks": "^4.6.0", 31 | "eslint-plugin-react-refresh": "^0.4.5", 32 | "typescript": "^5.6.2", 33 | "vite": "^5.4.5" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docs/example/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 2 | 3 | export default function App() { 4 | return ( 5 |
6 | 12 | } 13 | itemTwo={ 14 | 28 | } 29 | style={{ width: '100%', height: '50%' }} 30 | /> 31 | 38 | } 39 | itemTwo={ 40 | 54 | } 55 | style={{ width: '100%', height: '50%' }} 56 | /> 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /docs/example/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: system-ui, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | -webkit-text-size-adjust: 100%; 15 | } 16 | 17 | html, 18 | body, 19 | #root { 20 | display: flex; 21 | flex-grow: 1; 22 | width: 100%; 23 | height: 100%; 24 | min-height: 100%; 25 | overflow: hidden; 26 | } 27 | -------------------------------------------------------------------------------- /docs/example/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import packageJson from 'react-compare-slider/package.json'; 3 | import { createRoot } from 'react-dom/client'; 4 | 5 | import App from './App'; 6 | 7 | import './index.css'; 8 | 9 | console.info('react-compare-slider version:', packageJson.version); 10 | 11 | createRoot(document.getElementById('root') as HTMLElement).render( 12 | 13 | 14 | , 15 | ); 16 | -------------------------------------------------------------------------------- /docs/example/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /docs/example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /docs/example/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /docs/example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vite'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /docs/ssr-tests/README.md: -------------------------------------------------------------------------------- 1 | # SSR Tests 2 | 3 | Super basic test suite to make sure the lib doesn't error server-side. 4 | 5 | ## Usage 6 | 7 | ```sh 8 | pnpm run test 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/ssr-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@this/ssr-tests", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "", 6 | "author": "", 7 | "main": "ssr.test.js", 8 | "type": "module", 9 | "license": "MIT", 10 | "scripts": { 11 | "test": "rm -rf ./coverage && NODE_V8_COVERAGE='./coverage' node --experimental-test-coverage --test", 12 | "test-legacy": "node --test" 13 | }, 14 | "devDependencies": { 15 | "@types/node": "^22.5.5", 16 | "react": "^18.2.0", 17 | "react-compare-slider": "latest", 18 | "react-dom": "^18.2.0", 19 | "test": "^3.3.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docs/ssr-tests/ssr.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /* eslint no-console: 0 */ 3 | 4 | const assert = await import('node:assert'); 5 | const { describe, it } = await import('node:test'); 6 | 7 | const { createElement, isValidElement } = await import('react'); 8 | const { renderToStaticMarkup } = await import('react-dom/server'); 9 | // Using npm version of node built-in test lib to allow the test suite to run on node 16. 10 | const { MockTracker } = await import('test/lib/internal/test_runner/mock.js'); 11 | 12 | const { ReactCompareSlider, ReactCompareSliderHandle, ReactCompareSliderImage, styleFitContainer } = 13 | await import('react-compare-slider'); 14 | 15 | describe('SSR', () => { 16 | it('should render without error', () => { 17 | const mock = new MockTracker(); 18 | const mockConsoleError = mock.method(console, 'error'); 19 | const mockConsoleWarn = mock.method(console, 'warn'); 20 | 21 | const root = createElement(ReactCompareSlider, { 22 | handle: createElement(ReactCompareSliderHandle, {}), 23 | itemOne: createElement(ReactCompareSliderImage, { 24 | alt: 'Example 1', 25 | src: 'example-1.jpg', 26 | style: styleFitContainer({ objectPosition: 'left' }), 27 | }), 28 | itemTwo: createElement(ReactCompareSliderImage, { 29 | alt: 'Example 2', 30 | src: 'example-2.jpg', 31 | style: styleFitContainer({ objectPosition: 'left' }), 32 | }), 33 | }); 34 | 35 | /** 36 | * TypeScript errors if assertion isn't assigned. 37 | * @see https://github.com/microsoft/TypeScript/issues/36931 38 | */ 39 | let __ = assert.strictEqual(isValidElement(root), true); 40 | __ = assert.strictEqual(renderToStaticMarkup(root).includes('data-rcs="root"'), true); 41 | __ = assert.strictEqual(renderToStaticMarkup(root).includes('src="example-1.jpg"'), true); 42 | __ = assert.strictEqual(renderToStaticMarkup(root).includes('src="example-2.jpg"'), true); 43 | __ = assert.strictEqual(mockConsoleError.mock.calls.length, 0); 44 | __ = assert.strictEqual(mockConsoleWarn.mock.calls.length, 0); 45 | 46 | mock.reset(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /docs/storybook/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": false, 3 | "extends": "../../.eslintrc", 4 | "parserOptions": { 5 | "project": true 6 | }, 7 | "rules": { 8 | "no-console": 0, 9 | "@typescript-eslint/explicit-function-return-type": 0, 10 | "@typescript-eslint/no-explicit-any": "warn", 11 | "react/react-in-jsx-scope": 0 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/storybook/.storybook/hack-coverage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Storybook won't generate coverage for files outside of its root directory so the lib build script 3 | * copies the `src` dir into the Storybook project root. However, this means the coverage paths for 4 | * the SSR tests and Storybook tests aren't the same. This script updates the Storybook coverage 5 | * JSON to point to the actual `src` directory. 6 | */ 7 | const fs = require('node:fs'); 8 | const path = require('node:path'); 9 | 10 | console.info('[hack-coverage] 🙈 start'); 11 | 12 | const coveragePath = path.join(__dirname, '..', 'coverage', 'storybook', 'coverage-storybook.json'); 13 | 14 | const coverage = fs 15 | .readFileSync(coveragePath, 'utf8') 16 | .replaceAll('react-compare-slider/docs/storybook/src', 'react-compare-slider/lib/src'); 17 | 18 | fs.writeFileSync(coveragePath, coverage); 19 | 20 | console.info('[hack-coverage] 🙈 complete'); 21 | -------------------------------------------------------------------------------- /docs/storybook/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | import { resolve } from 'node:path'; 4 | 5 | import type { StorybookConfig } from '@storybook/react-vite'; 6 | import remarkGfm from 'remark-gfm'; 7 | import { mergeConfig } from 'vite'; 8 | 9 | const config: StorybookConfig = { 10 | core: { 11 | builder: '@storybook/builder-vite', 12 | disableWhatsNewNotifications: true, 13 | }, 14 | 15 | framework: { 16 | name: '@storybook/react-vite', 17 | options: { 18 | strictMode: true, 19 | }, 20 | }, 21 | 22 | addons: [ 23 | '@storybook/addon-essentials', 24 | { 25 | name: '@storybook/addon-docs', 26 | options: { 27 | mdxPluginOptions: { 28 | mdxCompileOptions: { 29 | remarkPlugins: [remarkGfm], 30 | }, 31 | }, 32 | }, 33 | }, 34 | '@storybook/addon-links', 35 | '@storybook/addon-interactions', 36 | '@storybook/jest', 37 | '@storybook/addon-coverage', 38 | '@storybook/addon-storysource', 39 | ], 40 | 41 | stories: ['../content/**/*.stories.@(mdx|tsx)'], 42 | 43 | viteFinal: async (config) => { 44 | const libPath = resolve(__dirname, '..', 'src'); 45 | console.info(`\n\n[SB CUSTOM] Using lib path: ${libPath}\n\n`); 46 | 47 | const finalConfig = mergeConfig(config, { 48 | resolve: { 49 | alias: { 50 | /** 51 | * @NOTE This alias is needed for Storybook to correctly generate docs. 52 | * Importing from the parent `src` directory does not fully work. 53 | */ 54 | 'react-compare-slider': libPath, 55 | }, 56 | }, 57 | }); 58 | 59 | return finalConfig; 60 | }, 61 | }; 62 | 63 | export default config; 64 | -------------------------------------------------------------------------------- /docs/storybook/.storybook/manager-head.html: -------------------------------------------------------------------------------- 1 | React Compare Slider 2 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/storybook/.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons'; 2 | import { ThemeVars } from '@storybook/theming'; 3 | import { create } from '@storybook/theming/create'; 4 | 5 | import { theme } from './theme'; 6 | 7 | /** 8 | * @see https://storybook.js.org/docs/configurations/options-parameter/ 9 | */ 10 | addons.setConfig({ 11 | showAddonsPanel: false, 12 | panelPosition: 'bottom', 13 | theme: create({ 14 | ...theme, 15 | brandTitle: 'React Compare Slider', 16 | brandUrl: 'https://github.com/nerdyman/react-compare-slider', 17 | gridCellSize: 12, 18 | } as ThemeVars), 19 | }); 20 | -------------------------------------------------------------------------------- /docs/storybook/.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 144 | -------------------------------------------------------------------------------- /docs/storybook/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Parameters } from '@storybook/react'; 2 | 3 | import { theme } from './theme'; 4 | 5 | import '@storybook/addon-console'; 6 | 7 | export const parameters: Parameters = { 8 | layout: 'fullscreen', 9 | controls: { 10 | hideNoControlsWarning: true, 11 | }, 12 | docs: { 13 | /** @see https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#docs-page */ 14 | docsPage: true, 15 | defaultName: 'Docs', 16 | story: { 17 | inline: true, 18 | }, 19 | theme, 20 | }, 21 | options: { 22 | showRoots: true, 23 | theme, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /docs/storybook/.storybook/theme.ts: -------------------------------------------------------------------------------- 1 | import { Theme, themes } from '@storybook/theming'; 2 | 3 | export const theme: Partial = { 4 | ...themes.dark, 5 | appBorderRadius: 3, 6 | colorSecondary: '#c86dfc', 7 | barSelectedColor: '#c86dfc', 8 | }; 9 | -------------------------------------------------------------------------------- /docs/storybook/content/00-introduction.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Canvas, Meta, Story } from '@storybook/blocks'; 2 | import React from 'react'; 3 | 4 | 5 | 6 |
7 | 8 |
9 | 10 | # React Compare Slider 11 | 12 | 13 | 14 | 15 | 16 | 27 | 28 | 39 | 40 | A lightweight and extensible slider component to compare any two React components in landscape or portrait orientation. 41 | It supports custom images, videos, canvases... and everything else. 42 | 43 |
44 | 45 | ## Features 46 | 47 | - Supports responsive images and any other React components (`picture`, `video`, `canvas`, `iframe` etc.) 48 | - Supports landscape and portrait orientations 49 | - Accessible – includes screen reader and keyboard support 50 | - Simple API 51 | - Unopinionated & fully customizable – optionally use your own components and styles 52 | - Responsive, intrinsic sizing 53 | - [Teeny-tiny](https://bundlephobia.com/result?p=react-compare-slider), zero dependencies 54 | - Type safe 55 | 56 | ## Requirements 57 | 58 | - React 16.8+ 59 | - The [latest two versions](https://github.com/nerdyman/react-compare-slider/blob/main/package.json#L55) of each major browser are officially supported 60 | 61 | ## Demos 62 | 63 | Storybook demos are within iframes which can sometimes cause the slider position to not 64 | meet the edges of the container when sliding quickly - this is a browser limitation and 65 | only occurs when the slider is within an iframe. 66 | 67 | ## Real World Examples 68 | 69 | - [Official GOV.UK Coronavirus Dashboard](https://coronavirus.data.gov.uk/details/interactive-map/vaccinations#vaccinations-map-container) 70 | - [Upscayl, Free and Open Source AI Image Upscaler](https://github.com/upscayl/upscayl#free-and-open-source-ai-image-upscaler) 71 | - [Counter-Strike 2 Website](https://www.counter-strike.net/cs2) 72 | 73 | --- 74 | 75 | View on GitHub 76 | 77 |
78 | -------------------------------------------------------------------------------- /docs/storybook/content/01-api.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Description, Meta, ArgTypes } from '@storybook/blocks'; 2 | 3 | import { 4 | ReactCompareSlider, 5 | ReactCompareSliderHandle, 6 | ReactCompareSliderImage, 7 | styleFitContainer, 8 | useReactCompareSliderRef, 9 | } from 'react-compare-slider'; 10 | 11 | 12 | 13 | # API 14 | 15 |
16 | 17 | ## `ReactCompareSlider` 18 | 19 | 20 | 21 | [🔗 Source](https://github.com/nerdyman/react-compare-slider/blob/main/lib/src/ReactCompareSlider.tsx) 22 | 23 | ### Props 24 | 25 | 26 | 27 | 28 | ## `ReactCompareSliderHandle` 29 | 30 | 31 | 32 | [🔗 Source](https://github.com/nerdyman/react-compare-slider/blob/main/lib/src/ReactCompareSliderHandle.tsx) 33 | 34 | ### Props 35 | 36 | 37 | 38 | 39 | ## `ReactCompareSliderImage` 40 | 41 | 42 | 43 | [🔗 Source](https://github.com/nerdyman/react-compare-slider/blob/main/lib/src/ReactCompareSliderImage.tsx) 44 | 45 | ### Props 46 | 47 | `ReactCompareSliderImage` accepts any valid HTML `img` component props. 48 | 49 | 50 | ## `styleFitContainer` 51 | 52 | 53 | 54 | [🔗 Source](https://github.com/nerdyman/react-compare-slider/blob/main/lib/src/utils.ts#L16) 55 | 56 | The `styleFitContainer` utility makes any [replaced element](https://developer.mozilla.org/en-US/docs/Web/CSS/Replaced_element) 57 | fill its parent while maintaining the correct aspect ratio. 58 | 59 | It returns a React `style` object and accepts a CSS object as an argument and 60 | defaults to `object-fit` to `cover`. 61 | 62 | ### Example 63 | 64 | Fill a full width/height container: 65 | 66 | ```jsx 67 | import { styleFitContainer } from 'react-compare-slider'; 68 | 69 |
70 |
79 | ``` 80 | 81 | ## `useReactCompareSliderRef` 82 | 83 | 84 | 85 | [🔗 Source](https://github.com/nerdyman/react-compare-slider/blob/main/lib/src/useReactCompareSliderRef.ts) 86 | 87 | The `useReactCompareSliderRef` hook provides a ref to the root element of the slider and exposes the internal 88 | function used to control the position of the slider. It offers performant programmatic control of the slider. 89 | 90 | Check out the [`useReactCompareSliderRef` docs](/story/docs-usereactcomparesliderref--page) more information. 91 | -------------------------------------------------------------------------------- /docs/storybook/content/01-images.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Canvas, Meta, Story } from '@storybook/blocks'; 2 | 3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 4 | 5 | 6 | 7 | # Using Images 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | ## `ReactCompareSliderImage` 16 | 17 | Images can be rendered using the `ReactCompareSliderImage` component and/or 18 | by using a custom image component, e.g. `picture` or `img`. 19 | 20 | `ReactCompareSliderImage` is a standalone image (`img`) component which uses the 21 | `styleFitContainer` utility for positioning and fitting. The defaults from `styleFitContainer` 22 | are applied automatically but can be overridden using the `style` prop. 23 | 24 |
25 | 26 | ### `ReactCompareSliderImage` Props 27 | 28 | `ReactCompareSliderImage` is a standard `img` component; it accepts all valid `img` props. 29 | 30 | ### Usage 31 | 32 | ```jsx 33 | import { ReactCompareSliderImage } from 'react-compare-slider'; 34 | 35 | /** Standard usage. */ 36 | 37 | 38 | /** Override `styleFitContainer` defaults. */ 39 | 44 | ``` 45 | 46 | ## Live Examples 47 | 48 | Checkout the [Images examples](/story/demos--images). 49 | -------------------------------------------------------------------------------- /docs/storybook/content/02-bounds-padding.stories.mdx: -------------------------------------------------------------------------------- 1 | import { ArgTypes, Canvas, Meta, Story } from '@storybook/blocks'; 2 | 3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 4 | 5 | 6 | 7 | # Using `boundsPadding` 8 | 9 | The `boundsPadding` prop allows you to limit the slideable area on the Y-axis 10 | in `portrait` mode or on the X-axis in the default landscape mode. Negative values are treated the 11 | same as `0` and the value **MUST** be supplied as a number in pixels. 12 | 13 | Bounds padding is useful when the slider has other components overlaying it. E.g. in a full 14 | width or height carousel with overlaying navigation buttons. 15 | 16 | In the example below, bounds padding prevents the slider handle from going 17 | within range of pixels of the left or right of the slider specified by the `boundsPadding` prop. 18 | The bounds are automatically applied to the top/bottom or right/left depending on the orientation of the slider. 19 | 20 | ```JSX 21 | boundsPadding={80} 22 | ``` 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 | 32 | ## Live Examples 33 | 34 | Checkout the [Bounds padding examples](/story/demos--bounds-padding). 35 | -------------------------------------------------------------------------------- /docs/storybook/content/03-browsing-context.stories.mdx: -------------------------------------------------------------------------------- 1 | import { ArgTypes, Canvas, Meta, Story } from '@storybook/blocks'; 2 | 3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 4 | 5 | 6 | 7 | # Using `browsingContext` 8 | 9 | The `browsingContext` prop allows you to change the target that events are bound to. By default, 10 | `globalThis` (`Window`) is used and will cover 99.999% of use cases. However, if you are using 11 | the library outside of current window context (e.g. an `iframe` or pop-up window) you will notice 12 | that not all the events are caputed by the slider. `browsingContext` can be used to set a different 13 | target to bind events to. 14 | 15 | For example, if you wanted to render the slider in a popup you would use `createPortal` from `react-dom` 16 | with the `document.body` property of the popup as the DOM node for `createPortal` and pass the popup 17 | window context as the `browsingContext` prop. 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | ## Live Examples 28 | 29 | Checkout the [Bounds padding examples](/story/demos--browsing-context). 30 | -------------------------------------------------------------------------------- /docs/storybook/content/03-change-position-on-hover.stories.mdx: -------------------------------------------------------------------------------- 1 | import { ArgsTable, Canvas, Meta, Story } from '@storybook/blocks'; 2 | 3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 4 | 5 | 6 | 7 | # Using `changePositionOnHover` 8 | 9 | The `changePositionOnHover` prop allows you to sync the slider position with the position 10 | of the mouse/pointer while the slider is being hovered over. 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | ## Live Examples 21 | 22 | Checkout the [Change Position on Hover examples](/story/demos--change-position-on-hover). 23 | 24 | -------------------------------------------------------------------------------- /docs/storybook/content/03-clip.stories.mdx: -------------------------------------------------------------------------------- 1 | import { ArgTypes, Canvas, Meta, Story } from '@storybook/blocks'; 2 | 3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 4 | 5 | 6 | 7 | # Using `clip` 8 | 9 | The `clip` prop allows you to control whether `itemOne`, `itemTwo`, or both items are clipped to 10 | the bounds of the slider. 11 | 12 | This is useful in cases where you have transparent items and want to control which item takes 13 | precedence when the slider is moved. 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | ## Live Examples 24 | 25 | Checkout the [Images example](/story/demos--images). 26 | -------------------------------------------------------------------------------- /docs/storybook/content/04-disabled.stories.mdx: -------------------------------------------------------------------------------- 1 | import { ArgTypes, Canvas, Meta, Story } from '@storybook/blocks'; 2 | 3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 4 | 5 | 6 | 7 | # Using `disabled` 8 | 9 | The `disabled` prop prevents the slider from being interacted with by the user and also applies 10 | the appropriate accessibility attributes to the slider controls. 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | ## Live Examples 21 | 22 | Checkout the [Disabled example](/story/demos--disabled). 23 | -------------------------------------------------------------------------------- /docs/storybook/content/05-handles.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Canvas, ArgTypes, Meta, Story } from '@storybook/blocks'; 2 | 3 | import { ReactCompareSliderHandle } from 'react-compare-slider'; 4 | 5 | 6 | 7 | # Using Handles 8 | 9 |

10 | 11 | **Note**: You should use a non-interactive elements (`div`, `span`, etc.) for custom `handle` 12 | components as handles are rendered within a `button` by the library. 13 | 14 |

15 | 16 | Custom Handles can be used via the `handle` prop on the main slider component. 17 | If a custom `handle` is not supplied `ReactCompareSliderHandle` will 18 | be used instead. 19 | 20 |
21 | 22 | ## `ReactCompareSliderHandle` 23 | 24 | `ReactCompareSliderHandle` supports the `portrait` prop to automatically flip 25 | itself to match the desired orientation. It also has individual props and class names to 26 | style the lines and button and accepts any valid `div` component props so you can easily 27 | customize it to fit your requirements. 28 | 29 | ### CSS `className` 30 | 31 | - `.__rcs-handle-root` - The root element. 32 | - `.__rcs-handle-button` - The circular element shown in the middle. 33 | - `.__rcs-handle-line` - For each line either side of the button. 34 | 35 |
36 | 37 | ### Props 38 | 39 | 40 | 41 | ### Custom `ReactCompareSliderHandle` usage 42 | 43 | The colors used in `ReactCompareSliderHandle` are inherited from the root element's `color` 44 | property using the `currentColor` keyword (except for `boxShadow`). 45 | To set all colors in sync just change the `style` property or use the `.__rcs-handle-root` class. 46 | 47 | 48 | 49 | 50 | 51 | ### Custom Standalone Handle Usage 52 | 53 | 54 | 55 | 56 | 57 | ## Live Examples 58 | 59 | Check out the [handles examples](/story/handles--inherited-color). 60 | -------------------------------------------------------------------------------- /docs/storybook/content/06-keyboard-increment.stories.mdx: -------------------------------------------------------------------------------- 1 | import { ArgsTable, Canvas, Meta, Story } from '@storybook/blocks'; 2 | 3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 4 | 5 | 6 | 7 | # Using `keyboardIncrement` 8 | 9 | The `keyboardIncrement` prop allows you to use the keyboard arrows to increment/decrement the slider 10 | position when the slider `handle` is focused. 11 | 12 | The increment can be either a `number` which evaluates to a pixel value or a `string` value 13 | ending in `%`, e.g. `20%` to move the slider 20% of the total slider width in landscape mode 14 | or height in portrait mode. A percentage is usually preferable as it adapts relatively to the width or 15 | height of the slider, making it behaving consistently across resolutions. 16 | 17 | If you want to effectively disable the keyboard increment, you can set the value to `0`. You can 18 | also set it to `'100%'` to flip between each side immediately. 19 | 20 | ```jsx 21 | // Increment by 20 pixels. 22 | 25 | 26 | // Increment by 20 percent of the slider width or height. 27 | 30 | ``` 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 | 40 | ## Live Examples 41 | 42 | Checkout the [Keyboard Increment examples](/story/demos--keyboard-increment). 43 | 44 | -------------------------------------------------------------------------------- /docs/storybook/content/07-only-handle-draggable.stories.mdx: -------------------------------------------------------------------------------- 1 | import { ArgTypes, Canvas, Meta, Story } from '@storybook/blocks'; 2 | 3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 4 | 5 | 6 | 7 | # Using `onlyHandleDraggable` 8 | 9 | The `onlyHandleDraggable` prop moves click, touch and drag from the main slider 10 | container to the handle. 11 | 12 | `onlyHandleDraggable` is useful for full width/height sliders on touch devices 13 | where the user needs to touch the slider container to scroll past it without changing the slider position. 14 | `onlyHandleDraggable` is also useful when comparing interactive components which 15 | have their own pointer events, such as canvas elements. 16 | 17 |
18 | 19 | ## Detecting Touch Devices 20 | 21 | The library doesn't provide a method to detect touch devices as it's simple to 22 | do natively. 23 | 24 | ```js 25 | const isTouchDevice = window.matchMedia('(pointer: coarse)').matches; 26 | ``` 27 | 28 | ## Live Examples 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | Also checkout the 45 | [Only Handle Draggable](/story/demos--only-handle-draggable) and 46 | [Detect Touch Devices](/story/recipes--detect-touch-devices) and 47 | [Google Maps](/story/recipes-google-maps--google-maps) demos. 48 | -------------------------------------------------------------------------------- /docs/storybook/content/08-transition.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Canvas, Meta, Story } from '@storybook/blocks'; 2 | 3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 4 | 5 | 6 | 7 | # Using `transition` 8 | 9 | The `transition` property applies a CSS transition when the slider is moved. The shorthand CSS 10 | property syntax is used **without** the `transition-property` value. The library sets which property 11 | to apply the transition to internally. 12 | 13 | You may notice that the slider sometimes moves immediately to the new position, this is because the 14 | transition only applies to `pointerdown` events to ensure the dragging movement of the slider is smooth. 15 | This means the transition will be cancelled if `pointerdown` is followed by a `pointermove` event. 16 | 17 | ```jsx 18 | 21 | ``` 22 | 23 | 24 | 25 | 26 | 27 | ## Live Examples 28 | 29 | Checkout the [Transition example](/story/demos--transition). 30 | -------------------------------------------------------------------------------- /docs/storybook/content/09-use-react-compare-slider-ref.stories.mdx: -------------------------------------------------------------------------------- 1 | import { ArgsTable, Canvas, Meta, Story } from '@storybook/blocks'; 2 | 3 | import { ReactCompareSlider } from 'react-compare-slider'; 4 | 5 | 6 | 7 | # Using the `useReactCompareSliderRef` Hook 8 | 9 |

10 | 11 | **Note**: Properties returned from the hook are only usable _after_ the component has mounted. 12 | 13 |

14 | 15 | The `useReactCompareSliderRef` hook allows you to access the root container as a ref (`rootContainer`) and provides 16 | access to the internal function used to performantly update the slider position (`setPosition`). 17 | 18 | | Property | Type | Description 19 | | :------------------| :---------------------------- | :---------------------------------------------- 20 | | `rootContainer` | `HTMLDivElement` | The root container DOM element. 21 | | `handleContainer` | `HTMLButtonElement` | The DOM element of the `handle` parent. 22 | | `setPosition` | `(position: number) => void` | Set the slider position to the given percentage. 23 | 24 |
25 | 26 | `setPosition` offers performant programmatic control of the slider. It is more performant because 27 | it does not trigger a re-render, as opposed to setting the `position` prop which will re-render the 28 | component then call the internal set position function as a side effect. 29 | 30 | Another benefit of `setPosition` is that it can be used to reset the slider position back to 31 | the initial position. This is not possible with the `position` prop. 32 | 33 | ```tsx 34 | const Example = () => { 35 | const reactCompareSliderRef = useReactCompareSliderRef(); 36 | 37 | // Safely use the ref properties in an effect or event handler callback. 38 | useEffect(() => { 39 | console.log(reactCompareSliderRef.current.rootContainer); // The root container DOM element. 40 | reactCompareSliderRef.current.setPosition(20); // Set the slider position to 20%. 41 | }, [reactCompareSliderRef]); 42 | 43 | return ( 44 | 47 | ); 48 | } 49 | 50 | ``` 51 | 52 | There's no limitation to `setPosition` usage, you can even use it to link sliders together! 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
61 | 62 | ## Live Examples 63 | 64 | Checkout the [useReactCompareSliderRef examples](/story/demos--use-react-compare-slider-ref). 65 | 66 | -------------------------------------------------------------------------------- /docs/storybook/content/99-real-world-examples.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | 3 | 4 | 5 | # Real-world Examples 6 | 7 | The library is used by many companies and over 1000 open source projects, below are some examples of 8 | how it's being used in the wild. 9 | 10 | - [upscaler.party: Image upscaler and comparator](https://upscaler.party/) 11 | - [removerized: Free AI-Powered Background Remover Tool](https://removerized.tech/) 12 | - [VideoGigaGAN: Detail-rich Video Super-Resolution](https://videogigagan.com/) 13 | - [Upscayl: Free and Open Source AI Image Upscaler](https://github.com/upscayl/upscayl#free-and-open-source-ai-image-upscaler) 14 | - [Counter-Strike 2 Website](https://www.counter-strike.net/cs2#Maps) and [Dota 2 Summer Client Update](https://www.dota2.com/summer2023) 15 | - [RoomGPT](https://github.com/Nutlope/roomGPT) 16 | -------------------------------------------------------------------------------- /docs/storybook/content/stories/00-demos/00-index.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryFn } from '@storybook/react'; 2 | import React, { useState } from 'react'; 3 | import type { ReactCompareSliderDetailedProps } from 'react-compare-slider'; 4 | import { 5 | ReactCompareSlider, 6 | ReactCompareSliderImage, 7 | useReactCompareSliderRef, 8 | } from 'react-compare-slider'; 9 | import { createPortal } from 'react-dom'; 10 | 11 | import { SLIDER_ROOT_TEST_ID } from '../99-tests/test-utils.test'; 12 | import { argTypes, args } from '../config'; 13 | 14 | const meta: Meta = { 15 | title: 'Demos', 16 | component: ReactCompareSlider, 17 | args, 18 | argTypes, 19 | }; 20 | export default meta; 21 | 22 | export const Images: StoryFn = (props) => { 23 | return ( 24 | 31 | } 32 | itemTwo={ 33 | 38 | } 39 | /> 40 | ); 41 | }; 42 | 43 | Images.args = { 44 | style: { 45 | width: '100%', 46 | backgroundColor: 'white', 47 | backgroundImage: ` 48 | linear-gradient(45deg, #ccc 25%, transparent 25%), 49 | linear-gradient(-45deg, #ccc 25%, transparent 25%), 50 | linear-gradient(45deg, transparent 75%, #ccc 75%), 51 | linear-gradient(-45deg, transparent 75%, #ccc 75%)`, 52 | backgroundSize: `20px 20px`, 53 | backgroundPosition: `0 0, 0 10px, 10px -10px, -10px 0px`, 54 | }, 55 | }; 56 | 57 | export const BoundsPadding: StoryFn = ({ 58 | boundsPadding = 80, 59 | ...props 60 | }) => { 61 | return ( 62 |
63 | 71 | } 72 | itemTwo={ 73 | 77 | } 78 | style={{ width: '100%', flexGrow: 1 }} 79 | /> 80 |
81 | ); 82 | }; 83 | 84 | BoundsPadding.args = { boundsPadding: 80 }; 85 | 86 | export const BrowsingContext: StoryFn = (props) => { 87 | const [browsingContext, setBrowsingContext] = useState(null); 88 | const reactCompareSliderRef = useReactCompareSliderRef(); 89 | 90 | return ( 91 |
92 | 95 | {browsingContext && 96 | createPortal( 97 | 104 | } 105 | itemTwo={ 106 | 110 | } 111 | ref={reactCompareSliderRef} 112 | browsingContext={browsingContext} 113 | />, 114 | browsingContext.document.body, 115 | )} 116 |
117 | ); 118 | }; 119 | 120 | BrowsingContext.args = {}; 121 | 122 | export const ChangePositionOnHover: StoryFn = ({ 123 | changePositionOnHover = true, 124 | ...props 125 | }) => { 126 | return ( 127 |
128 | 137 | } 138 | itemTwo={ 139 | 143 | } 144 | style={{ width: '100%', flexGrow: 1 }} 145 | /> 146 |
147 | ); 148 | }; 149 | 150 | ChangePositionOnHover.args = { changePositionOnHover: true }; 151 | 152 | export const Disabled: StoryFn = ({ disabled, ...props }) => { 153 | return ( 154 |
155 | 163 | } 164 | itemTwo={ 165 | 169 | } 170 | style={{ width: '100%', flexGrow: 1 }} 171 | /> 172 |
173 | ); 174 | }; 175 | 176 | Disabled.args = { disabled: true }; 177 | 178 | export const Handle: StoryFn = (props) => { 179 | const CustomHandle: React.FC = () => { 180 | return ( 181 |
209 | ); 210 | }; 211 | 212 | return ( 213 | } 216 | itemOne={ 217 | 221 | } 222 | itemTwo={ 223 | 227 | } 228 | style={{ width: '100%', height: '100vh' }} 229 | /> 230 | ); 231 | }; 232 | 233 | Handle.args = {}; 234 | 235 | export const KeyboardIncrement: StoryFn = (props) => { 236 | return ( 237 |
238 |
239 | Info: Click the slider handle then use the keyboard arrows to change the slider 240 | position. 241 |
242 | 243 | 250 | } 251 | itemTwo={ 252 | 256 | } 257 | style={{ width: '100%', flexGrow: 1 }} 258 | /> 259 |
260 | ); 261 | }; 262 | 263 | KeyboardIncrement.args = {}; 264 | 265 | export const OnlyHandleDraggable: StoryFn = ({ 266 | onlyHandleDraggable = true, 267 | ...props 268 | }) => { 269 | return ( 270 |
271 | 279 | } 280 | itemTwo={ 281 | 285 | } 286 | style={{ width: '100%', flexGrow: 1 }} 287 | /> 288 |
289 | ); 290 | }; 291 | 292 | OnlyHandleDraggable.args = { onlyHandleDraggable: true }; 293 | 294 | export const OnPositionChange: StoryFn = (props) => { 295 | const onPositionChange = React.useCallback((position) => { 296 | console.log('[OnPositionChange.onPositionChange]', position); 297 | }, []); 298 | 299 | return ( 300 |
301 |
302 | Note: This demo will be slightly laggy when viewing the action logging output in 303 | Storybook Actions tab. 304 |
305 | 306 | 314 | } 315 | itemTwo={ 316 | 320 | } 321 | style={{ width: '100%', flexGrow: 1 }} 322 | /> 323 |
324 | ); 325 | }; 326 | 327 | OnPositionChange.args = {}; 328 | 329 | export const Portrait: StoryFn = ({ 330 | portrait = true, 331 | ...props 332 | }) => ( 333 | 341 | } 342 | itemTwo={ 343 | 350 | } 351 | /> 352 | ); 353 | 354 | Portrait.args = { 355 | portrait: true, 356 | style: { 357 | width: '100%', 358 | height: '100vh', 359 | backgroundColor: 'white', 360 | backgroundImage: ` 361 | linear-gradient(45deg, #ccc 25%, transparent 25%), 362 | linear-gradient(-45deg, #ccc 25%, transparent 25%), 363 | linear-gradient(45deg, transparent 75%, #ccc 75%), 364 | linear-gradient(-45deg, transparent 75%, #ccc 75%)`, 365 | backgroundSize: `20px 20px`, 366 | backgroundPosition: `0 0, 0 10px, 10px -10px, -10px 0px`, 367 | }, 368 | }; 369 | 370 | export const Transition: StoryFn = (props) => { 371 | const reactCompareSliderRef = useReactCompareSliderRef(); 372 | 373 | React.useEffect(() => { 374 | const fireTransition = async () => { 375 | await new Promise((resolve) => 376 | setTimeout(() => { 377 | reactCompareSliderRef.current?.setPosition(90); 378 | resolve(true); 379 | }, 750), 380 | ); 381 | await new Promise((resolve) => 382 | setTimeout(() => { 383 | reactCompareSliderRef.current?.setPosition(10); 384 | resolve(true); 385 | }, 750), 386 | ); 387 | await new Promise((resolve) => 388 | setTimeout(() => { 389 | reactCompareSliderRef.current?.setPosition(50); 390 | resolve(true); 391 | }, 750), 392 | ); 393 | }; 394 | 395 | fireTransition(); 396 | }, []); 397 | 398 | return ( 399 | 407 | } 408 | itemTwo={ 409 | 416 | } 417 | style={{ width: '100%', height: '100vh' }} 418 | /> 419 | ); 420 | }; 421 | 422 | Transition.args = { 423 | position: 50, 424 | transition: '.75s ease-in-out', 425 | style: { 426 | backgroundColor: 'white', 427 | backgroundImage: ` 428 | linear-gradient(45deg, #ccc 25%, transparent 25%), 429 | linear-gradient(-45deg, #ccc 25%, transparent 25%), 430 | linear-gradient(45deg, transparent 75%, #ccc 75%), 431 | linear-gradient(-45deg, transparent 75%, #ccc 75%)`, 432 | backgroundSize: `20px 20px`, 433 | backgroundPosition: `0 0, 0 10px, 10px -10px, -10px 0px`, 434 | }, 435 | }; 436 | 437 | export const Position: StoryFn = ({ position = 25, ...props }) => ( 438 | 446 | } 447 | itemTwo={ 448 | 452 | } 453 | style={{ width: '100%', height: '100vh' }} 454 | /> 455 | ); 456 | 457 | Position.args = { position: 25 }; 458 | 459 | export const UseReactCompareSliderRef: StoryFn = (props) => { 460 | // We need to know which slider is in control to avoid infinite loops 🤯 461 | const [sliderInControl, setSliderInControl] = React.useState(1); 462 | const slider1Ref = useReactCompareSliderRef(); 463 | const slider2Ref = useReactCompareSliderRef(); 464 | 465 | const handlePosition1Change = React.useCallback( 466 | (position: number) => { 467 | if (sliderInControl === 1) { 468 | slider2Ref.current?.setPosition(position); 469 | } 470 | }, 471 | [slider2Ref, sliderInControl], 472 | ); 473 | 474 | const handlePosition2Change = React.useCallback( 475 | (position: number) => { 476 | if (sliderInControl === 2) { 477 | slider1Ref.current?.setPosition(position); 478 | } 479 | }, 480 | [slider1Ref, sliderInControl], 481 | ); 482 | 483 | return ( 484 | <> 485 |
486 | setSliderInControl(1)} 491 | onPointerDown={() => setSliderInControl(1)} 492 | onPositionChange={handlePosition1Change} 493 | itemOne={ 494 | 498 | } 499 | itemTwo={ 500 | 504 | } 505 | style={{ width: '50%' }} 506 | /> 507 | setSliderInControl(2)} 512 | onPointerDown={() => setSliderInControl(2)} 513 | onPositionChange={handlePosition2Change} 514 | itemOne={ 515 | 519 | } 520 | itemTwo={ 521 | 525 | } 526 | style={{ width: '50%' }} 527 | /> 528 | 529 | 544 |
545 | 546 | ); 547 | }; 548 | 549 | UseReactCompareSliderRef.args = {}; 550 | -------------------------------------------------------------------------------- /docs/storybook/content/stories/00-demos/02-edge-cases.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryFn } from '@storybook/react'; 2 | import type { ReactCompareSliderDetailedProps } from 'react-compare-slider'; 3 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 4 | 5 | import { argTypes, args } from '../config'; 6 | 7 | const meta: Meta = { 8 | title: 'Demos/Edge Cases', 9 | component: ReactCompareSlider, 10 | args, 11 | argTypes, 12 | }; 13 | export default meta; 14 | 15 | export const Scaled: StoryFn = ({ style, ...props }) => ( 16 | 23 | } 24 | itemTwo={ 25 | 29 | } 30 | style={{ width: '100%', height: '100vh', ...style }} 31 | /> 32 | ); 33 | 34 | Scaled.args = { 35 | style: { 36 | transform: 'scale(0.75)', 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /docs/storybook/content/stories/01-recipes/00-index.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryFn } from '@storybook/react'; 2 | import React from 'react'; 3 | import type { ReactCompareSliderDetailedProps } from 'react-compare-slider'; 4 | import { 5 | ReactCompareSlider, 6 | ReactCompareSliderHandle, 7 | ReactCompareSliderImage, 8 | useReactCompareSliderRef, 9 | } from 'react-compare-slider'; 10 | 11 | import { argTypes, args } from '../config'; 12 | 13 | const meta: Meta = { 14 | title: 'Recipes', 15 | component: ReactCompareSlider, 16 | args, 17 | argTypes, 18 | }; 19 | export default meta; 20 | 21 | export const ItemLabels: StoryFn = (props) => { 22 | const [labelOpacity, setLabelOpacity] = React.useState(1); 23 | 24 | const labelStyle = { 25 | fontSize: '1.25rem', 26 | position: 'absolute', 27 | padding: '1rem', 28 | color: 'white', 29 | opacity: labelOpacity, 30 | border: '2px solid white', 31 | borderRadius: '.5rem', 32 | backdropFilter: 'blur(0.25rem) saturate(180%) contrast(80%) brightness(120%)', 33 | WebkitBackdropFilter: 'blur(0.25rem) saturate(180%) contrast(80%) brightness(120%)', 34 | transition: 'opacity 0.25s ease-in-out', 35 | }; 36 | 37 | return ( 38 | setLabelOpacity(0)} 41 | onPointerUp={() => setLabelOpacity(1)} 42 | itemOne={ 43 |
52 |
Left
53 | 57 |
58 | } 59 | itemTwo={ 60 |
69 |
Right
70 | 74 |
75 | } 76 | /> 77 | ); 78 | }; 79 | 80 | ItemLabels.args = { 81 | style: { 82 | width: '100%', 83 | height: '100vh', 84 | }, 85 | }; 86 | 87 | export const HandleLabels: StoryFn = (props) => { 88 | const [labelOpacity, setLabelOpacity] = React.useState(1); 89 | 90 | const labelStyle = { 91 | fontSize: '.75rem', 92 | position: 'absolute', 93 | padding: '.25rem', 94 | color: 'white', 95 | opacity: labelOpacity, 96 | borderRadius: '.25rem', 97 | border: '1px solid white', 98 | backdropFilter: 'blur(0.25rem) saturate(180%) contrast(80%) brightness(120%)', 99 | WebkitBackdropFilter: 'blur(0.25rem) saturate(180%) contrast(80%) brightness(120%)', 100 | backgroundColor: 'rgba(0, 0, 0, 0.5)', 101 | transition: 'opacity 0.25s ease-in-out', 102 | }; 103 | 104 | return ( 105 | setLabelOpacity(0)} 108 | onPointerUp={() => setLabelOpacity(1)} 109 | itemOne={ 110 | 114 | } 115 | itemTwo={ 116 | 120 | } 121 | handle={ 122 |
123 | 124 |
Label 1
125 |
Label 2
126 |
127 | } 128 | /> 129 | ); 130 | }; 131 | 132 | HandleLabels.args = { 133 | style: { 134 | width: '100%', 135 | height: '100vh', 136 | }, 137 | }; 138 | 139 | export const DetectTouchDevices: StoryFn = (props) => { 140 | const isTouchDevice = window.matchMedia('(pointer: coarse)').matches; 141 | 142 | return ( 143 | <> 144 | 152 | } 153 | itemTwo={ 154 | 158 | } 159 | style={{ width: '100%', height: '100vh' }} 160 | /> 161 | 175 | Enable onlyHandleDraggable for touch devices only 176 |
177 | Is touch device: {String(isTouchDevice)} 178 |
179 | 180 | ); 181 | }; 182 | 183 | DetectTouchDevices.argTypes = { 184 | onlyHandleDraggable: { 185 | control: { 186 | disable: true, 187 | }, 188 | }, 189 | }; 190 | 191 | DetectTouchDevices.args = {}; 192 | 193 | export const WaitForImageLoad: StoryFn = (props) => { 194 | const [loaded, setLoaded] = React.useState(0); 195 | const imagesStyle = { 196 | opacity: loaded === 2 ? 1 : 0, 197 | transition: 'opacity 1s 0.5s ease-in-out', 198 | }; 199 | 200 | return ( 201 | setLoaded((prev) => prev + 1)} 209 | /> 210 | } 211 | itemTwo={ 212 | setLoaded((prev) => prev + 1)} 217 | /> 218 | } 219 | /> 220 | ); 221 | }; 222 | 223 | WaitForImageLoad.args = { 224 | style: { 225 | width: '100%', 226 | height: '100vh', 227 | backgroundColor: 'black', 228 | backgroundImage: 'radial-gradient(rgba(200, 109, 252, .5), rgba(39, 37, 39, .5))', 229 | }, 230 | }; 231 | 232 | export const ResetOnPointerLeave: StoryFn = (props) => { 233 | const reactCompareSliderRef = useReactCompareSliderRef(); 234 | 235 | return ( 236 |
237 | reactCompareSliderRef.current?.setPosition(props.position!)} 240 | ref={reactCompareSliderRef} 241 | itemOne={ 242 | 246 | } 247 | itemTwo={ 248 | 252 | } 253 | /> 254 | 255 |
256 | ); 257 | }; 258 | 259 | ResetOnPointerLeave.args = { 260 | style: { 261 | width: '100%', 262 | }, 263 | }; 264 | -------------------------------------------------------------------------------- /docs/storybook/content/stories/01-recipes/01-google-maps.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { GoogleMapProps } from '@react-google-maps/api'; 2 | import { GoogleMap as GoogleMapBase, useJsApiLoader } from '@react-google-maps/api'; 3 | import type { Meta, StoryFn } from '@storybook/react'; 4 | import React from 'react'; 5 | import type { ReactCompareSliderDetailedProps } from 'react-compare-slider'; 6 | import { ReactCompareSlider } from 'react-compare-slider'; 7 | 8 | import { argTypes, args } from '../config'; 9 | 10 | const meta: Meta = { 11 | title: 'Recipes/Google Maps', 12 | component: ReactCompareSlider, 13 | args, 14 | argTypes, 15 | }; 16 | export default meta; 17 | 18 | const useGoogleMap = () => { 19 | const [map, setMap] = React.useState(null); 20 | 21 | const onLoad = React.useCallback(function callback(nextMap) { 22 | setMap(nextMap); 23 | }, []); 24 | 25 | const onUnmount = React.useCallback(function callback() { 26 | setMap(null); 27 | }, []); 28 | 29 | return { onLoad, onUnmount, map }; 30 | }; 31 | 32 | const GoogleMap: React.FC = ({ isLoaded, ...props }) => { 33 | if (!isLoaded) return null; 34 | 35 | return ; 36 | }; 37 | 38 | const sharedMapProps: GoogleMapProps = { 39 | zoom: 14, 40 | tilt: 0, 41 | center: { lat: 54.9754478, lng: -1.6073616 }, 42 | mapContainerStyle: { 43 | width: '100%', 44 | height: '100%', 45 | }, 46 | }; 47 | 48 | export const GoogleMaps: StoryFn = (props) => { 49 | const { isLoaded } = useJsApiLoader({ 50 | id: 'google-map-script', 51 | googleMapsApiKey: 'AIzaSyAoRpWSXL16EnnFQqFfkRtfMCKJJTMzvk8', 52 | }); 53 | 54 | const mapOne = useGoogleMap(); 55 | const mapTwo = useGoogleMap(); 56 | const [zoom, setZoom] = React.useState(sharedMapProps.zoom); 57 | 58 | return ( 59 | mapTwo?.map?.setCenter(mapOne.map?.getCenter())} 67 | onZoomChanged={() => { 68 | setZoom(mapOne.map?.zoom || sharedMapProps.zoom); 69 | mapTwo?.map?.setCenter(mapOne.map?.getCenter()); 70 | }} 71 | zoom={zoom} 72 | /> 73 | } 74 | itemTwo={ 75 | mapOne?.map?.setCenter(mapTwo.map?.getCenter())} 80 | onZoomChanged={() => { 81 | setZoom(mapTwo.map?.zoom || sharedMapProps.zoom); 82 | mapOne?.map?.setCenter(mapTwo.map?.getCenter()); 83 | }} 84 | mapTypeId="satellite" 85 | zoom={zoom} 86 | /> 87 | } 88 | /> 89 | ); 90 | }; 91 | 92 | GoogleMaps.args = { 93 | style: { width: '100%', height: '100vh' }, 94 | onlyHandleDraggable: true, 95 | }; 96 | -------------------------------------------------------------------------------- /docs/storybook/content/stories/02-handles/00-react-compare-slider-handle.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryFn } from '@storybook/react'; 2 | import { useEffect } from 'react'; 3 | import type { ReactCompareSliderProps } from 'react-compare-slider'; 4 | import { 5 | ReactCompareSlider, 6 | ReactCompareSliderHandle, 7 | ReactCompareSliderImage, 8 | useReactCompareSliderRef, 9 | } from 'react-compare-slider'; 10 | 11 | import { argTypes, args } from '../config'; 12 | 13 | const meta: Meta = { 14 | title: 'Handles', 15 | component: ReactCompareSlider, 16 | args, 17 | argTypes, 18 | }; 19 | export default meta; 20 | 21 | export const InheritedColor: StoryFn = ({ portrait, ...props }) => ( 22 | } 26 | itemOne={ 27 | 32 | } 33 | itemTwo={ 34 | 38 | } 39 | style={{ width: '100%', height: '100vh' }} 40 | /> 41 | ); 42 | 43 | export const IndividualStyles: StoryFn = ({ portrait, ...props }) => ( 44 | 60 | } 61 | itemOne={ 62 | 67 | } 68 | itemTwo={ 69 | 73 | } 74 | style={{ width: '100%', height: '100vh' }} 75 | /> 76 | ); 77 | 78 | export const HideButton: StoryFn = ({ portrait, ...props }) => ( 79 | 89 | } 90 | itemOne={ 91 | 96 | } 97 | itemTwo={ 98 | 102 | } 103 | style={{ width: '100%', height: '100vh' }} 104 | /> 105 | ); 106 | 107 | export const HideLines: StoryFn = ({ portrait, ...props }) => ( 108 | 122 | } 123 | itemOne={ 124 | 129 | } 130 | itemTwo={ 131 | 135 | } 136 | style={{ width: '100%', height: '100vh' }} 137 | /> 138 | ); 139 | 140 | export const OverrideHandleContainerClick: StoryFn = (props) => { 141 | const reactCompareSliderRef = useReactCompareSliderRef(); 142 | 143 | useEffect(() => { 144 | /** 145 | * @NOTE The `containerClick` function is defined within the component for simplicity. If you're 146 | * not using any internal state within it, you can move it outside of the component to 147 | * avoid it being redefined on every effect change. 148 | */ 149 | const containerClick = (ev: MouseEvent) => { 150 | const container = ev.currentTarget as HTMLButtonElement; 151 | 152 | ev.preventDefault(); 153 | ev.stopImmediatePropagation(); 154 | container.focus({ preventScroll: true }); 155 | container.scrollIntoView({ behavior: 'smooth', block: 'center' }); 156 | }; 157 | 158 | const handleContainer = reactCompareSliderRef.current.handleContainer!; 159 | 160 | handleContainer.addEventListener('click', containerClick, { capture: true }); 161 | 162 | return () => { 163 | handleContainer.removeEventListener('click', containerClick, { capture: true }); 164 | }; 165 | }, []); 166 | 167 | return ( 168 |
169 |
170 | 171 |

172 | The useReactCompareSliderRef hook exposes the handleContainer{' '} 173 | property which points to the button element that contains the{' '} 174 | handle. By default, when the handleContainer or any elements 175 | within it are clicked, it focuses the handleContainer and moves the slider 176 | into view. This is for accessibility but you can override the behaviour as needed. In this 177 | example, instead of plainly focusing the slider, it focuses and smooth scrolls it into 178 | view. 179 |

180 | 181 |

182 | Note that this only occurs when the  183 | handleContainer or elements within the handleContainer are 184 | clicked. This is to allow custom itemOne|itemTwo components to be interacted 185 | with without the slider stealing focus. 186 |

187 |
188 | 197 | } 198 | itemTwo={ 199 | 203 | } 204 | style={{ width: '100%', minHeight: '100vh' }} 205 | /> 206 |
207 | ); 208 | }; 209 | 210 | OverrideHandleContainerClick.args = {}; 211 | -------------------------------------------------------------------------------- /docs/storybook/content/stories/02-handles/01-custom.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryFn } from '@storybook/react'; 2 | import React from 'react'; 3 | import type { ReactCompareSliderProps } from 'react-compare-slider'; 4 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 5 | 6 | import { argTypes, args } from '../config'; 7 | 8 | const meta: Meta = { 9 | title: 'Handles/Custom Component', 10 | component: ReactCompareSlider, 11 | args, 12 | argTypes, 13 | }; 14 | export default meta; 15 | 16 | export const CustomComponent: StoryFn = (props) => { 17 | const CustomHandle: React.FC = () => { 18 | return ( 19 |
47 | ); 48 | }; 49 | 50 | return ( 51 | } 54 | itemOne={ 55 | 59 | } 60 | itemTwo={ 61 | 65 | } 66 | style={{ width: '100%', height: '100vh' }} 67 | /> 68 | ); 69 | }; 70 | 71 | CustomComponent.args = {}; 72 | -------------------------------------------------------------------------------- /docs/storybook/content/stories/99-tests/clip.test.stories.tsx: -------------------------------------------------------------------------------- 1 | import { expect } from '@storybook/jest'; 2 | import type { Meta } from '@storybook/react'; 3 | import { fireEvent, waitFor, within } from '@storybook/testing-library'; 4 | import type { ReactCompareSlider } from 'react-compare-slider'; 5 | 6 | import { Template, getArgs } from './test-utils.test'; 7 | 8 | const meta: Meta = { 9 | title: 'Tests/Browser/Clip', 10 | }; 11 | export default meta; 12 | 13 | export const ClipBoth = Template.bind({}); 14 | ClipBoth.args = getArgs({ 15 | clip: 'both', 16 | style: { width: 256, height: 256 }, 17 | }); 18 | 19 | ClipBoth.play = async ({ canvasElement }) => { 20 | const canvas = within(canvasElement); 21 | const slider = canvas.getByRole('slider') as Element; 22 | const sliderRoot = (await canvas.findByTestId(ClipBoth.args?.['data-testid'])) as Element; 23 | 24 | await waitFor(() => expect(slider).toBeInTheDocument()); 25 | await waitFor(async () => expect(await canvas.findAllByRole('img')).toHaveLength(2)); 26 | await waitFor(() => { 27 | const [itemOne, itemTwo] = Array.from( 28 | sliderRoot.querySelectorAll('[data-rcs="clip-item"]'), 29 | ) as HTMLElement[]; 30 | expect(itemOne?.style.clipPath).toBe('inset(0px 50% 0px 0px)'); 31 | expect(itemTwo?.style.clipPath).toBe('inset(0px 0px 0px 50%)'); 32 | }); 33 | 34 | await new Promise((resolve) => setTimeout(resolve, 500)); 35 | 36 | await fireEvent.pointerDown(sliderRoot, { 37 | clientX: sliderRoot.clientWidth * 0.75, 38 | clientY: sliderRoot.clientHeight * 0.75, 39 | }); 40 | 41 | await new Promise((resolve) => setTimeout(resolve, 500)); 42 | 43 | await waitFor(() => { 44 | const [itemOne, itemTwo] = Array.from( 45 | sliderRoot.querySelectorAll('[data-rcs="clip-item"]'), 46 | ) as HTMLElement[]; 47 | 48 | expect(itemOne).toBeVisible(); 49 | expect(itemTwo).toBeVisible(); 50 | expect(itemOne?.style.clipPath).toBe('inset(0px 25% 0px 0px)'); 51 | expect(itemTwo?.style.clipPath).toBe('inset(0px 0px 0px 75%)'); 52 | }); 53 | }; 54 | 55 | export const ClipItemOne = Template.bind({}); 56 | ClipItemOne.args = getArgs({ 57 | clip: 'itemOne', 58 | style: { width: 256, height: 256 }, 59 | }); 60 | 61 | ClipItemOne.play = async ({ canvasElement }) => { 62 | const canvas = within(canvasElement); 63 | const slider = canvas.getByRole('slider') as Element; 64 | const sliderRoot = (await canvas.findByTestId(ClipBoth.args?.['data-testid'])) as Element; 65 | 66 | await waitFor(() => expect(slider).toBeInTheDocument()); 67 | await waitFor(async () => expect(await canvas.findAllByRole('img')).toHaveLength(2)); 68 | await waitFor(() => { 69 | const [itemOne, itemTwo] = Array.from( 70 | sliderRoot.querySelectorAll('[data-rcs="clip-item"]'), 71 | ) as HTMLElement[]; 72 | 73 | expect(itemOne).toBeVisible(); 74 | expect(itemTwo).toBeVisible(); 75 | expect(itemOne?.style.clipPath).toBe('inset(0px 50% 0px 0px)'); 76 | expect(itemTwo?.style.clipPath).toBe('none'); 77 | }); 78 | 79 | await new Promise((resolve) => setTimeout(resolve, 500)); 80 | 81 | await fireEvent.pointerDown(sliderRoot, { 82 | clientX: sliderRoot.clientWidth * 0.75, 83 | clientY: sliderRoot.clientHeight * 0.75, 84 | }); 85 | 86 | await new Promise((resolve) => setTimeout(resolve, 500)); 87 | 88 | await waitFor(() => { 89 | const [itemOne, itemTwo] = Array.from( 90 | sliderRoot.querySelectorAll('[data-rcs="clip-item"]'), 91 | ) as HTMLElement[]; 92 | 93 | expect(itemOne).toBeVisible(); 94 | expect(itemTwo).toBeVisible(); 95 | expect(itemOne?.style.clipPath).toBe('inset(0px 25% 0px 0px)'); 96 | expect(itemTwo?.style.clipPath).toBe('none'); 97 | }); 98 | }; 99 | 100 | export const ClipItemTwo = Template.bind({}); 101 | ClipItemTwo.args = getArgs({ 102 | clip: 'itemTwo', 103 | style: { width: 256, height: 256 }, 104 | }); 105 | 106 | ClipItemTwo.play = async ({ canvasElement }) => { 107 | const canvas = within(canvasElement); 108 | const slider = canvas.getByRole('slider') as Element; 109 | const sliderRoot = (await canvas.findByTestId(ClipBoth.args?.['data-testid'])) as Element; 110 | 111 | await waitFor(() => expect(slider).toBeInTheDocument()); 112 | await waitFor(async () => expect(await canvas.findAllByRole('img')).toHaveLength(2)); 113 | await waitFor(() => { 114 | const [itemOne, itemTwo] = Array.from( 115 | sliderRoot.querySelectorAll('[data-rcs="clip-item"]'), 116 | ) as HTMLElement[]; 117 | expect(itemOne?.style.clipPath).toBe('none'); 118 | expect(itemTwo?.style.clipPath).toBe('inset(0px 0px 0px 50%)'); 119 | }); 120 | 121 | await new Promise((resolve) => setTimeout(resolve, 500)); 122 | 123 | await fireEvent.pointerDown(sliderRoot, { 124 | clientX: sliderRoot.clientWidth * 0.25, 125 | clientY: sliderRoot.clientHeight * 0.25, 126 | }); 127 | 128 | await new Promise((resolve) => setTimeout(resolve, 500)); 129 | 130 | await waitFor(() => { 131 | const [itemOne, itemTwo] = Array.from( 132 | sliderRoot.querySelectorAll('[data-rcs="clip-item"]'), 133 | ) as HTMLElement[]; 134 | expect(itemOne?.style.clipPath).toBe('none'); 135 | expect(itemTwo?.style.clipPath).toBe('inset(0px 0px 0px 25%)'); 136 | }); 137 | }; 138 | -------------------------------------------------------------------------------- /docs/storybook/content/stories/99-tests/default.test.stories.tsx: -------------------------------------------------------------------------------- 1 | import { expect } from '@storybook/jest'; 2 | import type { Meta } from '@storybook/react'; 3 | import { waitFor, within } from '@storybook/testing-library'; 4 | import type { ReactCompareSlider } from 'react-compare-slider'; 5 | 6 | import { Template, getArgs } from './test-utils.test'; 7 | 8 | const meta: Meta = { 9 | title: 'Tests/Browser/Default', 10 | }; 11 | export default meta; 12 | 13 | /** Test default props. */ 14 | export const Default = Template.bind({}); 15 | Default.args = getArgs(); 16 | 17 | Default.play = async ({ canvasElement }) => { 18 | const canvas = within(canvasElement); 19 | const sliderRoot = (await canvas.findByRole('slider')) as HTMLDivElement; 20 | 21 | // Should have elements on mount. 22 | await waitFor(() => expect(canvas.getByAltText('one')).toBeInTheDocument()); 23 | await waitFor(() => expect(canvas.getByAltText('two')).toBeInTheDocument()); 24 | 25 | // Should have a11y attributes on mount. 26 | await waitFor(() => expect(sliderRoot).toHaveAttribute('aria-valuemin', '0')); 27 | await waitFor(() => expect(sliderRoot).toHaveAttribute('aria-valuemax', '100')); 28 | await waitFor(() => expect(sliderRoot).toHaveAttribute('aria-valuenow', '50')); 29 | await waitFor(() => 30 | expect(canvas.queryByLabelText('Drag to move or focus and use arrow keys')).toBeInTheDocument(), 31 | ); 32 | 33 | // Should have initial position on mount. 34 | await waitFor(() => expect(Default.args?.onPositionChange).toHaveBeenLastCalledWith(50)); 35 | }; 36 | -------------------------------------------------------------------------------- /docs/storybook/content/stories/99-tests/disabled.test.stories.tsx: -------------------------------------------------------------------------------- 1 | import { expect } from '@storybook/jest'; 2 | import type { Meta } from '@storybook/react'; 3 | import { userEvent, waitFor, within } from '@storybook/testing-library'; 4 | import type { ReactCompareSlider } from 'react-compare-slider'; 5 | 6 | import { Template, getArgs } from './test-utils.test'; 7 | 8 | const meta: Meta = { 9 | title: 'Tests/Browser/Disabled', 10 | }; 11 | export default meta; 12 | 13 | export const Disabled: typeof Template = (args) => ( 14 |
15 | 18 |