├── .babelrc ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc.toml ├── .storybook ├── main.ts └── preview.ts ├── LICENSE ├── README.md ├── biome.jsonc ├── package-lock.json ├── package.json ├── readme.gif ├── src ├── ReactCompareImage.tsx ├── global.d.ts ├── hooks │ └── useContainerWidth.ts ├── stories │ ├── RCI-complicated.stories.tsx │ ├── RCI.stories.tsx │ └── assets │ │ ├── anime.gif │ │ ├── image1-taller.png │ │ ├── image1.png │ │ ├── image2-wider.png │ │ ├── image2.png │ │ ├── images.ai │ │ ├── taller-image.png │ │ └── wider-image.png └── utils │ ├── calculateContainerHeight.test.ts │ ├── calculateContainerHeight.ts │ └── getImageRatio.ts ├── tsconfig.json ├── vite.config.mts └── vitest.config.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: junkboy0315 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'monthly' 7 | reviewers: 8 | - 'tam315' 9 | groups: 10 | all-at-once: 11 | patterns: 12 | - '*' 13 | 14 | - package-ecosystem: 'github-actions' 15 | directory: '/' 16 | schedule: 17 | interval: 'weekly' 18 | time: '03:00' 19 | timezone: 'Asia/Tokyo' 20 | reviewers: 21 | - 'tam315' 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Basic Tests 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | basic-tests: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version-file: 'package.json' 20 | 21 | - name: Install dependencies 22 | run: npm install 23 | 24 | - run: npx tsc 25 | 26 | - run: npx biome check 27 | 28 | - run: npx playwright install && npm run test 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist 3 | node_modules 4 | storybook-static 5 | yarn-error.log 6 | firebase-debug.log 7 | .idea 8 | 9 | *storybook.log 10 | 11 | .vscode -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # for n in * .[^.]**; do printf '%s\n' "$n"; done 2 | 3 | .all-contributorsrc 4 | .babelrc 5 | .DS_Store 6 | .eslintrc.js 7 | .git 8 | .github 9 | .gitignore 10 | .loki 11 | .npmignore 12 | .nvmrc 13 | .prettierrc.toml 14 | .storybook 15 | # dist 16 | images 17 | jest.config.js 18 | # LICENSE 19 | node_modules 20 | # package.json 21 | # README.md 22 | src 23 | stories 24 | storybook-static 25 | tsconfig.json 26 | webpack.production.config.js 27 | yarn.lock 28 | yarn-error.log -------------------------------------------------------------------------------- /.prettierrc.toml: -------------------------------------------------------------------------------- 1 | # アロー関数の引数が1つのときはカッコを付けない 2 | arrowParens = "avoid" 3 | singleQuote = true 4 | tabWidth = 2 5 | trailingComma = "all" 6 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite' 2 | 3 | const config: StorybookConfig = { 4 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 5 | addons: [ 6 | '@storybook/addon-onboarding', 7 | '@storybook/addon-essentials', 8 | '@chromatic-com/storybook', 9 | '@storybook/addon-interactions', 10 | ], 11 | framework: { 12 | name: '@storybook/react-vite', 13 | options: {}, 14 | }, 15 | } 16 | export default config 17 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react' 2 | 3 | const preview: Preview = { 4 | parameters: { 5 | options: { 6 | storySort: { 7 | order: ['ReactCompareImage', ['Basic', 'Advanced']], 8 | }, 9 | }, 10 | controls: { 11 | matchers: { 12 | color: /(background|color)$/i, 13 | date: /Date$/i, 14 | }, 15 | }, 16 | }, 17 | } 18 | 19 | export default preview 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Shota Tamura 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Compare Image 2 | 3 | [![All Contributors](https://img.shields.io/badge/all_contributors-8-orange.svg?style=flat-square)](#contributors-) 4 | 5 | Buy Me A Coffee 6 | 7 | Simple React component to compare two images using slider. 8 | 9 | ![img](https://raw.githubusercontent.com/tam315/react-compare-image/refs/heads/main/readme.gif) 10 | 11 | NOTE: [Vue.js Version](https://github.com/junkboy0315/vue-compare-image) is also available! 12 | 13 | ## Demo & Sample codes 14 | 15 | [Demo & Sample codes](https://react-compare-image.yuuniworks.com/) 16 | 17 | ## Features 18 | 19 | - Simple 20 | - Responsive (always fit to the parent width) 21 | - Horizontal & Vertical comparison 22 | 23 | ## How to use 24 | 25 | ```sh 26 | yarn add react-compare-image 27 | // or 28 | npm install --save react-compare-image 29 | ``` 30 | 31 | Note: Version 1 or later works only with React16.8 or later. Use version 0 instead. 32 | 33 | ```jsx 34 | import ReactCompareImage from 'react-compare-image'; 35 | 36 | ; 37 | ``` 38 | 39 | ## Props 40 | 41 | | Prop (\* required) | type | default | description | 42 | | ------------------------ | ----------------------- | :---------: | --------------------------------------------------------------------------------------------------------------------- | 43 | | aspectRatio | `'taller'` or `'wider'` | `'taller'` | Which to choose if the aspect ratios of the images are different | 44 | | handle | element | null | Custom handle element. Just pass `` if you want to remove handle. | 45 | | handleSize | number (px) | 40 | diameter of slider handle (by pixel) | 46 | | hover | boolean | false | Whether to slide at hover | 47 | | leftImage \* | string | null | left image's url | 48 | | leftImageAlt | string | `''` | alt props for left image | 49 | | leftImageCss | object | {} | Additional css for left image | 50 | | leftImageLabel | string | null | Label for the image (e.g. `before`) | 51 | | onSliderPositionChange | function | null | Callback function called each time the slider changes. The position (0 to 1) of the slider is passed as arg | 52 | | rightImage \* | string | null | right image's url | 53 | | rightImageAlt | string | `''` | alt props for right image | 54 | | rightImageCss | object | {} | Additional css for right image | 55 | | rightImageLabel | string | null | Label for the image (e.g. `after`) | 56 | | skeleton | element | null | Element displayed while image is loading | 57 | | sliderLineColor | string | `'#ffffff'` | line color of slider | 58 | | sliderLineWidth | number (px) | 2 | line width of slider (by pixel) | 59 | | sliderPositionPercentage | number (float) | 0.5 | Default line position (from 0 to 1) | 60 | | vertical | boolean | false | Compare images vertically instead of horizontally. The left image is on the top and the right image is on the bottom. | 61 | 62 | ## Supported browzer 63 | 64 | Latest modern browsers(Chrome, Safari, Firefox, Edge) 65 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "formatter": { 7 | "enabled": true, 8 | "indentStyle": "space", 9 | "indentWidth": 2, 10 | "ignore": ["**/tsconfig*.json", "package.json", "package-lock.yaml"] 11 | }, 12 | "linter": { 13 | "enabled": true, 14 | "rules": { 15 | "all": true, 16 | "style": { 17 | "useNamingConvention": "off", 18 | "useFilenamingConvention": "off", 19 | "noDefaultExport": "off" 20 | }, 21 | "correctness": {} 22 | } 23 | }, 24 | "javascript": { 25 | "formatter": { 26 | "quoteStyle": "single", 27 | "jsxQuoteStyle": "double", 28 | "trailingCommas": "all", 29 | "semicolons": "asNeeded" 30 | } 31 | }, 32 | "json": { 33 | "parser": { 34 | // for tsconfig 35 | "allowComments": true 36 | } 37 | }, 38 | // don't touch files in .gitignore 39 | "vcs": { 40 | "enabled": true, 41 | "clientKind": "git", 42 | "useIgnoreFile": true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-compare-image", 3 | "version": "3.5.4", 4 | "description": "React component to compare two images using slider.", 5 | "main": "./dist/ReactCompareImage.umd.js", 6 | "module": "./dist/ReactCompareImage.mjs", 7 | "types": "./dist/ReactCompareImage.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/ReactCompareImage.d.ts", 11 | "require": "./dist/ReactCompareImage.umd.js", 12 | "import": "./dist/ReactCompareImage.mjs" 13 | } 14 | }, 15 | "scripts": { 16 | "build": "vite build", 17 | "prepublishOnly": "npm run build", 18 | "storybook": "storybook dev -p 6006", 19 | "build-storybook": "storybook build", 20 | "test": "vitest --browser.headless", 21 | "test:ui": "vitest" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "junkboy0315/react-compare-image" 26 | }, 27 | "author": "Shota Tamura", 28 | "license": "MIT", 29 | "peerDependencies": { 30 | "react": "^16.8 || ^17 || ^18 || ^19", 31 | "react-dom": "^16.8 || ^17 || ^18 || ^19" 32 | }, 33 | "devDependencies": { 34 | "@babel/preset-env": "^7.26.9", 35 | "@babel/preset-react": "^7.26.3", 36 | "@babel/preset-typescript": "^7.27.0", 37 | "@biomejs/biome": "^1.9.4", 38 | "@chromatic-com/storybook": "^3.2.6", 39 | "@storybook/addon-essentials": "^8.6.12", 40 | "@storybook/addon-interactions": "^8.6.12", 41 | "@storybook/addon-onboarding": "^8.6.12", 42 | "@storybook/blocks": "^8.6.12", 43 | "@storybook/react": "^8.6.12", 44 | "@storybook/react-vite": "^8.6.12", 45 | "@storybook/test": "^8.6.12", 46 | "@types/react": "^19.1.2", 47 | "@types/react-dom": "^19.1.2", 48 | "@types/resize-observer-browser": "^0.1.11", 49 | "@vitejs/plugin-react": "^4.2.1", 50 | "@vitest/browser": "^3.1.1", 51 | "@vitest/ui": "^3.1.1", 52 | "jsdom": "^26.1.0", 53 | "playwright": "^1.52.0", 54 | "react": "^19.0.0", 55 | "react-dom": "^19.0.0", 56 | "storybook": "^8.6.12", 57 | "typescript": "^5.8.3", 58 | "vite": "^6.3.1", 59 | "vite-plugin-dts": "^4.5.3", 60 | "vite-tsconfig-paths": "^5.1.4", 61 | "vitest": "^3.1.1", 62 | "vitest-browser-react": "^0.1.1" 63 | }, 64 | "keywords": [ 65 | "picture comparison", 66 | "image comparison", 67 | "slider", 68 | "react", 69 | "twentytwenty" 70 | ], 71 | "browserslist": [ 72 | "last 1 Chrome versions", 73 | "last 1 Safari versions", 74 | "last 1 Firefox versions", 75 | "last 1 Edge versions", 76 | "last 1 ChromeAndroid versions", 77 | "last 1 iOS versions" 78 | ], 79 | "volta": { 80 | "node": "22.14.0" 81 | }, 82 | "dependencies": { 83 | "tiny-invariant": "^1.3.3" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /readme.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tam315/react-compare-image/99b3fafe4eb4906ff8ce3fcb0000565796810839/readme.gif -------------------------------------------------------------------------------- /src/ReactCompareImage.tsx: -------------------------------------------------------------------------------- 1 | import useContainerWidth from '@/hooks/useContainerWidth' 2 | import { calculateContainerHeight } from '@/utils/calculateContainerHeight' 3 | import { getImageRatio } from '@/utils/getImageRatio' 4 | import { 5 | type CSSProperties, 6 | type ReactNode, 7 | useCallback, 8 | useEffect, 9 | useRef, 10 | useState, 11 | } from 'react' 12 | import invariant from 'tiny-invariant' 13 | 14 | interface ReactCompareImageProps { 15 | aspectRatio?: 'taller' | 'wider' 16 | handle?: ReactNode 17 | handleSize?: number 18 | hover?: boolean 19 | leftImage: string 20 | leftImageAlt?: string 21 | leftImageCss?: object 22 | leftImageLabel?: string 23 | onSliderPositionChange?: (position: number) => void 24 | rightImage: string 25 | rightImageAlt?: string 26 | rightImageCss?: object 27 | rightImageLabel?: string 28 | skeleton?: ReactNode 29 | sliderLineColor?: string 30 | sliderLineWidth?: number 31 | sliderPositionPercentage?: number 32 | vertical?: boolean 33 | } 34 | 35 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 36 | const ReactCompareImage = (props: ReactCompareImageProps) => { 37 | const { 38 | aspectRatio = 'taller', 39 | handle = null, 40 | handleSize = 40, 41 | hover = false, 42 | leftImage, 43 | leftImageAlt = '', 44 | leftImageCss = {}, 45 | leftImageLabel = null, 46 | onSliderPositionChange, 47 | rightImage, 48 | rightImageAlt = '', 49 | rightImageCss = {}, 50 | rightImageLabel = null, 51 | skeleton = null, 52 | sliderLineColor = '#ffffff', 53 | sliderLineWidth = 2, 54 | sliderPositionPercentage = 0.5, 55 | vertical = false, 56 | } = props 57 | 58 | const horizontal = !vertical 59 | 60 | // 0 to 1 61 | const [sliderPosition, setSliderPosition] = useState( 62 | sliderPositionPercentage, 63 | ) 64 | const [isSliding, setIsSliding] = useState(false) 65 | 66 | // size of the parent container 67 | const [containerWidth, setContainerWidth] = useState(0) 68 | const [containerHeight, setContainerHeight] = useState(0) 69 | 70 | // refs to HTML elements 71 | const containerRef = useContainerWidth((width) => setContainerWidth(width)) 72 | const rightImageRef = useRef(null) 73 | const leftImageRef = useRef(null) 74 | 75 | // image loading flag 76 | const [imagesLoaded, setImagesLoaded] = useState(false) 77 | const checkImagesLoaded = useCallback(() => { 78 | if (leftImageRef.current?.complete && rightImageRef.current?.complete) { 79 | setImagesLoaded(true) 80 | } 81 | }, []) 82 | 83 | // Manage image loading state 84 | // biome-ignore lint/correctness/useExhaustiveDependencies: 85 | useEffect(() => { 86 | // Sometimes onLoad is not called for some reason (maybe due to cache). 87 | // So check explicitly. 88 | checkImagesLoaded() 89 | 90 | return () => { 91 | setImagesLoaded(false) 92 | } 93 | }, [leftImage, rightImage]) 94 | 95 | // Set container height based on the image ratio 96 | useEffect(() => { 97 | if ( 98 | !(leftImageRef.current && rightImageRef.current) || 99 | containerWidth === 0 || 100 | !imagesLoaded 101 | ) { 102 | return 103 | } 104 | const height = calculateContainerHeight( 105 | containerWidth, 106 | getImageRatio(leftImageRef.current), 107 | getImageRatio(rightImageRef.current), 108 | aspectRatio, 109 | ) 110 | setContainerHeight(height) 111 | }, [containerWidth, imagesLoaded, aspectRatio]) 112 | 113 | // Setup event listeners for mouse/touch events. 114 | // We need to reset the event handlers whenever the container’s width or 115 | // any other relevant condition changes. 116 | // 117 | // biome-ignore lint/correctness/useExhaustiveDependencies: `onSliderPositionChange` is a prop and may cause infinite loop 118 | useEffect(() => { 119 | // do nothing if refs are not ready for some reason 120 | if (!containerRef.current) { 121 | return 122 | } 123 | 124 | // wait for image loading 125 | if (!imagesLoaded) { 126 | return 127 | } 128 | 129 | const handleSliding = (e: MouseEvent | TouchEvent) => { 130 | if (!containerRef.current) { 131 | return 132 | } 133 | 134 | // Get the cursor position from the edge of the container 135 | const rect = containerRef.current.getBoundingClientRect() 136 | let clientX: number 137 | let clientY: number 138 | if (e instanceof TouchEvent) { 139 | const touch = e.touches[0] 140 | invariant(touch) 141 | clientX = touch.clientX 142 | clientY = touch.clientY 143 | } else { 144 | clientX = e.clientX 145 | clientY = e.clientY 146 | } 147 | const position = horizontal ? clientX - rect.left : clientY - rect.top 148 | 149 | // Prevent slider from overflowing container by clamping its position within bounds 150 | const halfLineWidth = sliderLineWidth / 2 151 | const maxPosition = horizontal 152 | ? containerWidth - halfLineWidth 153 | : containerHeight - halfLineWidth 154 | const clampedPosition = Math.min( 155 | Math.max(position, halfLineWidth), 156 | maxPosition, 157 | ) 158 | 159 | const ratio = 160 | clampedPosition / (horizontal ? containerWidth : containerHeight) 161 | 162 | setSliderPosition(ratio) 163 | onSliderPositionChange?.(ratio) 164 | } 165 | 166 | const startSliding = (e: MouseEvent | TouchEvent) => { 167 | setIsSliding(true) 168 | 169 | // Prevent default behavior other than mobile scrolling 170 | if (!('touches' in e)) { 171 | e.preventDefault() 172 | } 173 | 174 | // Slide the image even if you just click or tap (not drag) 175 | handleSliding(e) 176 | 177 | window.addEventListener('mousemove', handleSliding) // 07 178 | window.addEventListener('touchmove', handleSliding) // 08 179 | } 180 | 181 | const finishSliding = () => { 182 | setIsSliding(false) 183 | window.removeEventListener('mousemove', handleSliding) 184 | window.removeEventListener('touchmove', handleSliding) 185 | } 186 | 187 | const containerElement = containerRef.current 188 | 189 | // for mobile 190 | containerElement.addEventListener('touchstart', startSliding) // 01 191 | window.addEventListener('touchend', finishSliding) // 02 192 | 193 | // for desktop 194 | if (hover) { 195 | containerElement.addEventListener('mousemove', handleSliding) // 03 196 | containerElement.addEventListener('mouseleave', finishSliding) // 04 197 | } else { 198 | containerElement.addEventListener('mousedown', startSliding) // 05 199 | window.addEventListener('mouseup', finishSliding) // 06 200 | } 201 | 202 | return () => { 203 | // clean up all event listeners 204 | containerElement.removeEventListener('touchstart', startSliding) // 01 205 | window.removeEventListener('touchend', finishSliding) // 02 206 | containerElement.removeEventListener('mousemove', handleSliding) // 03 207 | containerElement.removeEventListener('mouseleave', finishSliding) // 04 208 | containerElement.removeEventListener('mousedown', startSliding) // 05 209 | window.removeEventListener('mouseup', finishSliding) // 06 210 | window.removeEventListener('mousemove', handleSliding) // 07 211 | window.removeEventListener('touchmove', handleSliding) // 08 212 | } 213 | }, [ 214 | imagesLoaded, 215 | aspectRatio, 216 | containerHeight, 217 | containerWidth, 218 | horizontal, 219 | hover, 220 | sliderLineWidth, 221 | containerRef, 222 | // onSliderPositionChange, // may cause infinite loop 223 | ]) 224 | 225 | const styles = { 226 | container: { 227 | boxSizing: 'border-box', 228 | position: 'relative', 229 | width: '100%', 230 | height: `${containerHeight}px`, 231 | overflow: 'hidden', 232 | }, 233 | rightImage: { 234 | clipPath: horizontal 235 | ? `inset(0px 0px 0px ${containerWidth * sliderPosition}px)` 236 | : `inset(${containerHeight * sliderPosition}px 0px 0px 0px)`, 237 | display: 'block', 238 | height: '100%', 239 | objectFit: 'cover', 240 | position: 'absolute', 241 | width: '100%', 242 | ...rightImageCss, 243 | }, 244 | leftImage: { 245 | clipPath: horizontal 246 | ? `inset(0px ${containerWidth * (1 - sliderPosition)}px 0px 0px)` 247 | : `inset(0px 0px ${containerHeight * (1 - sliderPosition)}px 0px)`, 248 | display: 'block', 249 | height: '100%', 250 | objectFit: 'cover', 251 | position: 'absolute', 252 | width: '100%', 253 | ...leftImageCss, 254 | }, 255 | slider: { 256 | alignItems: 'center', 257 | display: 'flex', 258 | justifyContent: 'center', 259 | position: 'absolute', 260 | ...(horizontal 261 | ? { 262 | cursor: 'ew-resize', 263 | flexDirection: 'column', 264 | height: '100%', 265 | left: `${containerWidth * sliderPosition - handleSize / 2}px`, 266 | top: 0, 267 | width: `${handleSize}px`, 268 | } 269 | : { 270 | cursor: 'ns-resize', 271 | flexDirection: 'row', 272 | height: `${handleSize}px`, 273 | left: 0, 274 | top: `${containerHeight * sliderPosition - handleSize / 2}px`, 275 | width: '100%', 276 | }), 277 | }, 278 | line: { 279 | background: sliderLineColor, 280 | boxShadow: 281 | '0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12)', 282 | flex: '0 1 auto', 283 | height: horizontal ? '100%' : `${sliderLineWidth}px`, 284 | width: horizontal ? `${sliderLineWidth}px` : '100%', 285 | }, 286 | handleCustom: { 287 | alignItems: 'center', 288 | boxSizing: 'border-box', 289 | display: 'flex', 290 | flex: '1 0 auto', 291 | height: 'auto', 292 | justifyContent: 'center', 293 | width: 'auto', 294 | }, 295 | handleDefault: { 296 | alignItems: 'center', 297 | border: `${sliderLineWidth}px solid ${sliderLineColor}`, 298 | borderRadius: '100%', 299 | boxShadow: 300 | '0px 3px 1px -2px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 1px 5px 0px rgba(0, 0, 0, 0.12)', 301 | boxSizing: 'border-box', 302 | display: 'flex', 303 | flex: '1 0 auto', 304 | height: `${handleSize}px`, 305 | justifyContent: 'center', 306 | width: `${handleSize}px`, 307 | transform: horizontal ? 'none' : 'rotate(90deg)', 308 | }, 309 | leftArrow: { 310 | border: `inset ${handleSize * 0.15}px rgba(0,0,0,0)`, 311 | borderRight: `${handleSize * 0.15}px solid ${sliderLineColor}`, 312 | height: '0px', 313 | marginLeft: `-${handleSize * 0.25}px`, // for IE11 314 | marginRight: `${handleSize * 0.25}px`, 315 | width: '0px', 316 | }, 317 | rightArrow: { 318 | border: `inset ${handleSize * 0.15}px rgba(0,0,0,0)`, 319 | borderLeft: `${handleSize * 0.15}px solid ${sliderLineColor}`, 320 | height: '0px', 321 | marginRight: `-${handleSize * 0.25}px`, // for IE11 322 | width: '0px', 323 | }, 324 | leftLabel: { 325 | background: 'rgba(0, 0, 0, 0.5)', 326 | color: 'white', 327 | left: horizontal ? '5%' : '50%', 328 | opacity: isSliding ? 0 : 1, 329 | padding: '10px 20px', 330 | position: 'absolute', 331 | top: horizontal ? '50%' : '3%', 332 | transform: horizontal ? 'translate(0,-50%)' : 'translate(-50%, 0)', 333 | transition: 'opacity 0.1s ease-out', 334 | }, 335 | rightLabel: { 336 | background: 'rgba(0, 0, 0, 0.5)', 337 | color: 'white', 338 | opacity: isSliding ? 0 : 1, 339 | padding: '10px 20px', 340 | position: 'absolute', 341 | ...(horizontal 342 | ? { 343 | right: '5%', 344 | top: '50%', 345 | transform: 'translate(0,-50%)', 346 | } 347 | : { 348 | left: '50%', 349 | bottom: '3%', 350 | transform: 'translate(-50%, 0)', 351 | }), 352 | transition: 'opacity 0.1s ease-out', 353 | }, 354 | leftLabelContainer: { 355 | clipPath: horizontal 356 | ? `inset(0px ${containerWidth * (1 - sliderPosition)}px 0px 0px)` 357 | : `inset(0px 0px ${containerHeight * (1 - sliderPosition)}px 0px)`, 358 | height: '100%', 359 | position: 'absolute', 360 | width: '100%', 361 | }, 362 | rightLabelContainer: { 363 | clipPath: horizontal 364 | ? `inset(0px 0px 0px ${containerWidth * sliderPosition}px)` 365 | : `inset(${containerHeight * sliderPosition}px 0px 0px 0px)`, 366 | height: '100%', 367 | position: 'absolute', 368 | width: '100%', 369 | }, 370 | } as const satisfies { [key: string]: CSSProperties } 371 | 372 | return ( 373 | <> 374 | {skeleton && !imagesLoaded && ( 375 |
{skeleton}
376 | )} 377 | 378 |
386 | checkImagesLoaded()} 388 | alt={rightImageAlt} 389 | data-testid="right-image" 390 | ref={rightImageRef} 391 | src={rightImage} 392 | style={styles.rightImage} 393 | /> 394 | checkImagesLoaded()} 396 | alt={leftImageAlt} 397 | data-testid="left-image" 398 | ref={leftImageRef} 399 | src={leftImage} 400 | style={styles.leftImage} 401 | /> 402 |
403 |
404 | {handle ? ( 405 |
{handle}
406 | ) : ( 407 |
408 |
409 |
410 |
411 | )} 412 |
413 |
414 | {/* labels */} 415 | {leftImageLabel && ( 416 |
417 |
{leftImageLabel}
418 |
419 | )} 420 | {rightImageLabel && ( 421 |
422 |
{rightImageLabel}
423 |
424 | )} 425 |
426 | 427 | ) 428 | } 429 | 430 | export default ReactCompareImage 431 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | // Storyでpngファイルをimportするところでエラーが出るので 2 | declare module '*.png' { 3 | const src: string 4 | export default src 5 | } 6 | -------------------------------------------------------------------------------- /src/hooks/useContainerWidth.ts: -------------------------------------------------------------------------------- 1 | import { type RefObject, useLayoutEffect, useRef } from 'react' 2 | import invariant from 'tiny-invariant' 3 | 4 | type ResizeCallback = (width: number) => void 5 | 6 | /** 7 | * Triggers a callback when an element's width changes. 8 | * 9 | * @param onResize Called when the element's width changes 10 | * @returns ref: A ref to be attached to the element 11 | */ 12 | export default function useContainerWidth( 13 | onResize: ResizeCallback, 14 | ): RefObject { 15 | const ref = useRef(null) 16 | 17 | useLayoutEffect(() => { 18 | if (!ref.current) { 19 | return 20 | } 21 | 22 | const observer = new ResizeObserver((entries) => { 23 | invariant(entries[0], 'ResizeObserver should have at least one entry') 24 | onResize(entries[0].contentRect.width) 25 | }) 26 | observer.observe(ref.current) 27 | // Initial call 28 | onResize(ref.current.getBoundingClientRect().width) 29 | 30 | return () => { 31 | observer.disconnect() 32 | } 33 | }, [onResize]) 34 | 35 | return ref 36 | } 37 | -------------------------------------------------------------------------------- /src/stories/RCI-complicated.stories.tsx: -------------------------------------------------------------------------------- 1 | import ReactCompareImage from '@/ReactCompareImage' 2 | import type { Meta, StoryObj } from '@storybook/react' 3 | import { useState } from 'react' 4 | import img1Src from './assets/image1.png' 5 | import img2Src from './assets/image2.png' 6 | import imgTaller from './assets/taller-image.png' 7 | import imgWider from './assets/wider-image.png' 8 | 9 | const meta: Meta = { 10 | title: 'ReactCompareImage/ForDebugging', 11 | } 12 | 13 | export default meta 14 | 15 | export const VariousWidths: StoryObj = { 16 | render: () => ( 17 |
18 |
19 | 20 |
21 |

22 |

23 | 24 |
25 |

26 |

27 | 28 |
29 |

30 |

31 | 32 |
33 |
34 | ), 35 | } 36 | 37 | export const UpdateImage: StoryObj = { 38 | render: () => { 39 | const [leftImageSrc, setLeftImageSrc] = useState(img1Src) 40 | const [rightImageSrc, setRightImageSrc] = useState(img2Src) 41 | 42 | return ( 43 |
44 |
45 | 49 |
50 | 53 | 56 |
57 | ) 58 | }, 59 | } 60 | 61 | export const Resizing: StoryObj = { 62 | render: () => ( 63 |
72 | 73 |
74 | ), 75 | } 76 | -------------------------------------------------------------------------------- /src/stories/RCI.stories.tsx: -------------------------------------------------------------------------------- 1 | import ReactCompareImage from '@/ReactCompareImage' 2 | import type { Meta, StoryObj } from '@storybook/react' 3 | import { Fragment } from 'react' 4 | import img1TallerSrc from './assets/image1-taller.png' 5 | import img1Src from './assets/image1.png' 6 | import img2WiderSrc from './assets/image2-wider.png' 7 | import img2Src from './assets/image2.png' 8 | import ImgTallerSrc from './assets/taller-image.png' 9 | import ImgWiderSrc from './assets/wider-image.png' 10 | 11 | const meta: Meta = { 12 | title: 'ReactCompareImage/Basic', 13 | component: ReactCompareImage, 14 | } 15 | 16 | export default meta 17 | type Story = StoryObj 18 | 19 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 20 | export const Basic: Story = { 21 | args: { 22 | leftImage: img1Src, 23 | rightImage: img2Src, 24 | }, 25 | } 26 | 27 | export const Vertical: Story = { 28 | args: { 29 | leftImage: img1Src, 30 | rightImage: img2Src, 31 | vertical: true, 32 | }, 33 | } 34 | 35 | export const Hover: Story = { 36 | args: { 37 | leftImage: img1Src, 38 | rightImage: img2Src, 39 | hover: true, 40 | }, 41 | } 42 | 43 | export const LabelHorizontal: Story = { 44 | args: { 45 | leftImage: img1Src, 46 | rightImage: img2Src, 47 | leftImageLabel: 'Before', 48 | rightImageLabel: 'After', 49 | }, 50 | } 51 | 52 | export const LabelVertical: Story = { 53 | args: { 54 | leftImage: img1Src, 55 | rightImage: img2Src, 56 | leftImageLabel: 'Before', 57 | rightImageLabel: 'After', 58 | vertical: true, 59 | }, 60 | } 61 | 62 | export const ApplyCss: Story = { 63 | args: { 64 | leftImage: img1Src, 65 | rightImage: img2Src, 66 | leftImageCss: { filter: 'brightness(40%)' }, 67 | rightImageCss: { filter: 'brightness(20%)' }, 68 | }, 69 | } 70 | 71 | export const SliderCustomization: Story = { 72 | args: { 73 | leftImage: img1Src, 74 | rightImage: img2Src, 75 | sliderLineWidth: 10, 76 | sliderLineColor: 'rebeccapurple', 77 | handle: , 78 | }, 79 | } 80 | 81 | export const NoHandleNoSlider: Story = { 82 | args: { 83 | leftImage: img1Src, 84 | rightImage: img2Src, 85 | handle: , 86 | sliderLineWidth: 0, 87 | }, 88 | } 89 | 90 | export const SliderPosition: Story = { 91 | args: { 92 | leftImage: img1Src, 93 | rightImage: img2Src, 94 | onSliderPositionChange: (position: number) => { 95 | // biome-ignore lint/suspicious/noConsole: 96 | // biome-ignore lint/suspicious/noConsoleLog: 97 | console.log('Slider position:', position) 98 | }, 99 | }, 100 | } 101 | 102 | export const Taller: Story = { 103 | args: { 104 | leftImage: ImgTallerSrc, 105 | rightImage: ImgWiderSrc, 106 | aspectRatio: 'taller', 107 | }, 108 | } 109 | 110 | export const Wider: Story = { 111 | args: { 112 | leftImage: ImgTallerSrc, 113 | rightImage: ImgWiderSrc, 114 | aspectRatio: 'wider', 115 | }, 116 | } 117 | 118 | export const SameWidthComparison: Story = { 119 | args: { 120 | leftImage: img1Src, 121 | rightImage: img1TallerSrc, 122 | aspectRatio: 'taller', 123 | rightImageCss: { objectFit: 'contain', objectPosition: 'top' }, 124 | leftImageCss: { objectFit: 'contain', objectPosition: 'top' }, 125 | }, 126 | } 127 | 128 | export const SameHeightComparison: Story = { 129 | args: { 130 | leftImage: img2WiderSrc, 131 | rightImage: img2Src, 132 | aspectRatio: 'wider', 133 | rightImageCss: { objectFit: 'contain', objectPosition: 'left' }, 134 | leftImageCss: { objectFit: 'contain', objectPosition: 'left' }, 135 | sliderPositionPercentage: 0.95, 136 | }, 137 | } 138 | -------------------------------------------------------------------------------- /src/stories/assets/anime.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tam315/react-compare-image/99b3fafe4eb4906ff8ce3fcb0000565796810839/src/stories/assets/anime.gif -------------------------------------------------------------------------------- /src/stories/assets/image1-taller.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tam315/react-compare-image/99b3fafe4eb4906ff8ce3fcb0000565796810839/src/stories/assets/image1-taller.png -------------------------------------------------------------------------------- /src/stories/assets/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tam315/react-compare-image/99b3fafe4eb4906ff8ce3fcb0000565796810839/src/stories/assets/image1.png -------------------------------------------------------------------------------- /src/stories/assets/image2-wider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tam315/react-compare-image/99b3fafe4eb4906ff8ce3fcb0000565796810839/src/stories/assets/image2-wider.png -------------------------------------------------------------------------------- /src/stories/assets/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tam315/react-compare-image/99b3fafe4eb4906ff8ce3fcb0000565796810839/src/stories/assets/image2.png -------------------------------------------------------------------------------- /src/stories/assets/images.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tam315/react-compare-image/99b3fafe4eb4906ff8ce3fcb0000565796810839/src/stories/assets/images.ai -------------------------------------------------------------------------------- /src/stories/assets/taller-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tam315/react-compare-image/99b3fafe4eb4906ff8ce3fcb0000565796810839/src/stories/assets/taller-image.png -------------------------------------------------------------------------------- /src/stories/assets/wider-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tam315/react-compare-image/99b3fafe4eb4906ff8ce3fcb0000565796810839/src/stories/assets/wider-image.png -------------------------------------------------------------------------------- /src/utils/calculateContainerHeight.test.ts: -------------------------------------------------------------------------------- 1 | import { calculateContainerHeight } from '@/utils/calculateContainerHeight' 2 | import { expect, it } from 'vitest' 3 | 4 | it('chooses the larger ratio when aspect is "taller"', () => { 5 | const containerWidth = 200 6 | const leftRatio = 0.5 // height is half of width 7 | const rightRatio = 0.25 // height is a quarter of width 8 | const result = calculateContainerHeight( 9 | containerWidth, 10 | leftRatio, 11 | rightRatio, 12 | 'taller', 13 | ) 14 | // For "taller", uses max(0.5, 0.25) = 0.5 15 | expect(result).toBe(containerWidth * leftRatio) 16 | }) 17 | 18 | it('chooses the smaller ratio when aspect is "wider"', () => { 19 | const containerWidth = 300 20 | const leftRatio = 0.75 21 | const rightRatio = 0.5 22 | const result = calculateContainerHeight( 23 | containerWidth, 24 | leftRatio, 25 | rightRatio, 26 | 'wider', 27 | ) 28 | // For "wider", uses min(0.75, 0.5) = 0.5 29 | expect(result).toBe(containerWidth * rightRatio) 30 | }) 31 | 32 | it('works correctly when both ratios are equal', () => { 33 | const containerWidth = 150 34 | const ratio = 0.4 35 | const resultTaller = calculateContainerHeight( 36 | containerWidth, 37 | ratio, 38 | ratio, 39 | 'taller', 40 | ) 41 | const resultWider = calculateContainerHeight( 42 | containerWidth, 43 | ratio, 44 | ratio, 45 | 'wider', 46 | ) 47 | // Both should be the same: containerWidth * ratio 48 | expect(resultTaller).toBe(containerWidth * ratio) 49 | expect(resultWider).toBe(containerWidth * ratio) 50 | }) 51 | -------------------------------------------------------------------------------- /src/utils/calculateContainerHeight.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculates the container height based on its width and the aspect ratios of two images. 3 | * 4 | * @param containerWidth - The width of the container in pixels. 5 | * @param leftRatio - The width-to-height ratio (naturalHeight / naturalWidth) of the left image. 6 | * @param rightRatio - The width-to-height ratio (naturalHeight / naturalWidth) of the right image. 7 | * @param aspect - Aspect mode: 'taller' chooses the larger ratio, 'wider' chooses the smaller ratio. 8 | * @returns The calculated container height in pixels. 9 | */ 10 | export function calculateContainerHeight( 11 | containerWidth: number, 12 | leftRatio: number, 13 | rightRatio: number, 14 | aspect: 'taller' | 'wider', 15 | ): number { 16 | const idealRatio = 17 | aspect === 'taller' 18 | ? Math.max(leftRatio, rightRatio) 19 | : Math.min(leftRatio, rightRatio) 20 | return containerWidth * idealRatio 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/getImageRatio.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the natural height/width ratio of an image element. 3 | * @param image - HTMLImageElement whose ratio is computed 4 | * @returns The image's width-to-height ratio. 5 | */ 6 | export function getImageRatio(image: HTMLImageElement): number { 7 | return image.naturalHeight / image.naturalWidth 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "moduleResolution": "node", 5 | // allow default import 6 | "allowSyntheticDefaultImports": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "jsx": "react-jsx", 10 | "outDir": "dist", 11 | "lib": ["dom", "es2015"], 12 | "skipLibCheck":true, 13 | "paths": { 14 | "@/*": ["./src/*"] 15 | }, 16 | "strict": true, 17 | "noUncheckedIndexedAccess": true, 18 | "noErrorTruncation": false, // エラーを中略しない 19 | "noFallthroughCasesInSwitch": true, 20 | "noImplicitOverride": true, 21 | "noImplicitReturns": true, 22 | "noPropertyAccessFromIndexSignature": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /vite.config.mts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import react from '@vitejs/plugin-react' 3 | import { defineConfig } from 'vite' 4 | import dts from 'vite-plugin-dts' 5 | import tsconfigPaths from 'vite-tsconfig-paths' 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | tsconfigPaths(), 11 | dts({ 12 | include: ['src/ReactCompareImage.tsx'], 13 | outDir: 'dist', 14 | }), 15 | ], 16 | build: { 17 | lib: { 18 | entry: 'src/ReactCompareImage.tsx', 19 | name: 'ReactCompareImage', 20 | fileName: 'ReactCompareImage', 21 | }, 22 | rollupOptions: { 23 | external: ['react', 'react-dom'], 24 | output: { 25 | globals: { 26 | react: 'React', 27 | 'react-dom': 'ReactDOM', 28 | }, 29 | }, 30 | }, 31 | outDir: 'dist', 32 | }, 33 | }) 34 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore lint/correctness/noNodejsModules: 2 | import path from 'node:path' 3 | import { defineConfig } from 'vitest/config' 4 | 5 | export default defineConfig({ 6 | resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, 7 | test: { 8 | browser: { 9 | enabled: true, 10 | provider: 'playwright', 11 | instances: [{ browser: 'chromium' }], 12 | }, 13 | include: ['src/**/*.test.{ts,tsx}'], 14 | }, 15 | }) 16 | --------------------------------------------------------------------------------