Thursday, January 25, 2024

swipe before/after slider in react and elsewhere

For work we wanted to look into making a side-by-side/before-after slider/swipe tool thing. There are number of those available for React, but many of them seem hard coded to do images only, not arbitrary DOM stuff.

As a warm up I handrolled a version in P5.js - it's still just using images but it gave me an idea of what I was up against. (It's sneakily tricky to make a cut out like this-- since they virtually occupy the same space, you can't just have one in front of the other.)

I found two ways to do it in P5, where "mid" is the X-coordinate change over point:

You can draw the left image, and then start drawing the right image at mid, making sure to draw from the offscreen image at that point only:  

image(imgBefore, 0, 0,width,height);
image(imgAfter, mid, 0,width,height,mid,0,width,height)

or you can draw the right image, and then just part of the left image:

image(imgAfter, 0, 0,width,height);
copy(imgBefore, 0, 0, mid, height, 0, 0, mid, height);

But of course we wanted to do it in React. I fired up ChatGPT to give a hand and came up with the content of this CodePen. (I also did a version with the images just so it looked better)

The JS was mostly about the slider control and the jokey content, but it had that critical clipPath which was the special sauce

const { useState, useRef } = React;

const BeforeAfterSlider = () => {
  const [sliderPosition, setSliderPosition] = useState(50);
  const sliderContainerRef = useRef(null);

  const handleMouseDown = () => {
    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);
  };

  const handleMouseMove = (e) => {
    const rect = sliderContainerRef.current.getBoundingClientRect();
    let newPosition = (e.clientX - rect.left) / rect.width * 100;
// Clamp between 0 and 100
    newPosition = Math.max(0, Math.min(newPosition, 100)); 
    setSliderPosition(newPosition);
  };

  const handleMouseUp = () => {
    document.removeEventListener('mousemove', handleMouseMove);
    document.removeEventListener('mouseup', handleMouseUp);
  };

  
const SampleContent = ({className}) => {
  return <div className={className}>Lorem ipsum dolor sit amet, 
  consectetur adipiscing elit, sed do eiusmod tempor incididunt 
  ut labore et dolore magna aliqua. Ut enim ad minim veniam, 
  quis nostrud exercitation ullamco laboris nisi ut aliquip ex 
  ea commodo consequat. Duis aute irure dolor in reprehenderit 
  in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 
  Excepteur sint occaecat cupidatat non proident, sunt in culpa qui 
  officia deserunt mollit anim id est laborum.</div>; 
}
  
return (
  <div className="slider-container" ref={sliderContainerRef}>
    <div 
      className="before-content" 
      style={{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }} 
    >
      <SampleContent className='beforeGuts'/>
      </div>
    <div 
      className="after-content" 
      style={{ clipPath: `inset(0 0 0 ${sliderPosition}%)` }} 
    >
    <SampleContent className='afterGuts'/>
      </div>
      
    <div
      className="slider-handle"
      style={{ left: `${sliderPosition}%` }}
      onMouseDown={handleMouseDown}
    />
  </div>
);
  
};
ReactDOM.render(, document.getElementById('root'));


oh and there was some CSS

.slider-container {
  position: relative;
  width: 100%;
  max-width: 600px; 
  height: 300px; 
  overflow: hidden;
}

.before-content,
.after-content {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  background-size: cover;
  background-position: center;
  clip-path: inset(0 50% 0 0); /* Initially half */
}

.before-content {

}

.after-content {

  clip-path: inset(0 0 0 50%); /* Initially half */
}

.slider-handle {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 5px;
  background: black;
  cursor: ew-resize;
}

.beforeGuts,.afterGuts{
  font-size:20px;
  font-family:sans-serif;
  font-weight:bold;
  width:600px;
  background-color:white;
}

.beforeGuts {
  color: red; 
}
.afterGuts {
  color: blue; 
}

Took some iterating with ChatGPT but we got there.


UPDATE: here is my final version using Styled-Components, and changing the interface to use the first 2 children....(note with styled components I had to use to the attrs() instead of creating new classes each time the slider moved:


import React, { useState, useRef } from 'react';

import styled from 'styled-components';

// Styled components
const SliderContainer = styled.div`
    position: relative;
    width: 100%;
    height: 100%;
    overflow: hidden;
`;

const Content = styled.div.attrs((props) => ({
    style: {
        clipPath: props.clipPath,
    },
}))`
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    background-size: cover;
    background-position: center;
`;

const SliderHandle = styled.div.attrs((props) => ({
    style: {
        left: props.left,
    },
}))`
    position: absolute;
    top: 0;
    bottom: 0;
    width: 5px;
    background: red;
    cursor: ew-resize;
    display: flex;
    align-items: center;
`;

const SliderHandleCircle = styled.div`
    width: 40px;
    height: 40px;
    background-color: transparent;
    border: 6px solid red;
    border-radius: 50%;
    position: absolute;
    left: -18px;
`;

// we are assuming exactly two children
const SwipeSlider = ({ children }) => {
    const [sliderPosition, setSliderPosition] = useState(50);
    const sliderContainerRef = useRef(null);

    const [leftContent, rightContent] = children;

    const handleMouseDown = () => {
        document.addEventListener('mousemove', handleMouseMove);
        document.addEventListener('mouseup', handleMouseUp);
    };

    const handleMouseMove = (e) => {
        const rect = sliderContainerRef.current.getBoundingClientRect();
        let newPosition = ((e.clientX - rect.left) / rect.width) * 100;
        newPosition = Math.max(0, Math.min(newPosition, 100)); // Clamp between 0 and 100
        setSliderPosition(newPosition);
    };

    const handleMouseUp = () => {
        document.removeEventListener('mousemove', handleMouseMove);
        document.removeEventListener('mouseup', handleMouseUp);
    };

    return (
        <SliderContainer ref={sliderContainerRef}>
            <Content className="left-content-wrapper" clipPath={`inset(0 ${100 - sliderPosition}% 0 0)`}>
                {leftContent}
            </Content>
            <Content className="right-content-wrapper" clipPath={`inset(0 0 0 ${sliderPosition}%)`}>
                {rightContent}
            </Content>

            <SliderHandle left={`${sliderPosition}%`} onMouseDown={handleMouseDown}>
                <SliderHandleCircle />
            </SliderHandle>
        </SliderContainer>
    );
};

export default SwipeSlider;

No comments:

Post a Comment