Recreating a Tailwind CSS Dual Theme Toggle Slider Component Using Tailwind CSS and React
June 14, 2025
In this blog post, I'll walk you through how I recreated the Dual Theme Toggle Slider component featured on the official Tailwind CSS landing page. This interactive UI lets users drag a center divider to seamlessly toggle between light and dark themes — a sleek and modern way to showcase theme variations.
Understanding the Component
This component works by layering two images on top of each other — one representing the light theme and one the dark theme. A draggable vertical bar allows users to control how much of the light theme is visible, creating a split-view effect. It gives users an instant feel of both themes in one interaction.
Setting Up the Environment
We are using React and Tailwind CSS for styling. No external UI library is used, but we've manually implemented all the logic using basic hooks and DOM events. Make sure your project is set up with:
- Tailwind CSS
- React (Vite or Create React App)
Setting Up Dotted Background Effect
Let's first create the basic layout of the component. This includes setting up the container with Dotted Background. The dotted effect in the background is generated using a combination of custom CSS variables and Tailwind's arbitrary values for background image and size:
import React from "react";
const index = () => {
return (
<div className="bg-[#030712] p-5 flex justify-center items-center m-auto">
<div className="h-112 p-4 sm:p-8 relative overflow-hidden rounded-lg border border-gray-800 bg-gray-950/[2.5%] bg-[image:radial-gradient(var(--pattern-fg)_1px,_transparent_0)] bg-[size:10px_10px] bg-fixed [--pattern-fg:var(--color-white)]/10">
</div>
</div>
);
};
export default index;
bg-[image:radial-gradient(var(--pattern-fg)_1px,_transparent_0)]
bg-[size:10px_10px]
bg-fixed
[--pattern-fg:var(--color-white)]/10
Breakdown
- bg-[image:radial-gradient(var(--pattern-fg)_1px,_transparent_0)] - This defines a custom background image using a radial gradient. It draws tiny 1px circular dots using the --pattern-fg color, spaced out with transparency in between.
- bg-[size:10px_10px] - Sets the size of each tile in the pattern to 10px × 10px, creating an evenly spaced grid of dots.
- bg-fixed - Makes the background fixed to the viewport, so it doesn't scroll with the content, enhancing the subtle visual effect.
- [--pattern-fg:var(--color-white)]/10 - Defines a custom CSS variable --pattern-fg using Tailwind's opacity utility /10, which applies 10% opacity to the white color — making the dots soft and less distracting.
We should have something like this:

Adding Both Light and Dark Theme Images
Setting up the light and dark theme images, and styling the layout using Tailwind CSS to closely match the original design from the Tailwind CSS landing page.
import React from "react";
import ImageComparison from "./components/ImageComparison";
const index = () => {
return (
<div className="bg-[#030712] p-5 flex justify-center items-center m-auto">
<div className="h-112 p-4 sm:p-8 relative overflow-hidden rounded-lg border border-gray-800 bg-gray-950/[2.5%] bg-[image:radial-gradient(var(--pattern-fg)_1px,_transparent_0)] bg-[size:10px_10px] bg-fixed [--pattern-fg:var(--color-white)]/10">
<div className="w-full overflow-hidden">
<ImageComparison />
</div>
</div>
</div>
);
};
export default index;
import React from "react";
const ImageComparison = () => {
return (
<div className="isolate flex h-full w-full items-center justify-center">
<div className="h-[30.5rem] w-[375px] relative grid grid-cols-1 grid-rows-1 overflow-hidden rounded-t-4xl bg-gray-950/10 outline outline-gray-950/10 dark:outline-white/10">
<div className="col-start-1 row-start-1">
<img
src="/DualThemeToggleSlider/img/light.png"
className="absolute inset-0"
alt="Light theme"
/>
</div>
<div className="col-start-1 row-start-1">
<img
src="/DualThemeToggleSlider/img/dark.png"
className="absolute inset-0"
alt="Dark theme"
/>
</div>
</div>
</div>
);
};
export default ImageComparison;
We should have something like this:

Note
inset-0 is a Tailwind CSS utility class that sets all four inset properties — top, right, bottom, and left — to 0.
Adding Draggable Divider
In this step, we introduce a Draggable Divider to visually separate the light and dark theme images. We start by adding a new state variable:
const [translateX, setTranslateX] = useState(187.5);
Here, 187.5 is exactly half the width of our image container (375px / 2), which ensures the divider starts centered by default.
import React, { useState } from "react";
import ImageComparison from "./components/ImageComparison";
const index = () => {
const [translateX, setTranslateX] = useState(187.5);
return (
<div className="bg-[#030712] p-5 flex justify-center items-center m-auto">
<div className="h-112 p-4 sm:p-8 relative overflow-hidden rounded-lg border border-gray-800 bg-gray-950/[2.5%] bg-[image:radial-gradient(var(--pattern-fg)_1px,_transparent_0)] bg-[size:10px_10px] bg-fixed [--pattern-fg:var(--color-white)]/10">
<div className="w-full overflow-hidden relative">
{/* Draggable Divider */}
<div
className="absolute inset-y-0 z-10 w-1 bg-sky-400 cursor-ew-resize"
style={{ transform: `translateX(${translateX}px)` }}
></div>
<ImageComparison translateX={translateX} />
</div>
</div>
</div>
);
};
export default index;
Note
We then use this value in the transform: translateX(...) style to position the divider horizontally.
import React from "react";
const ImageComparison = ({ translateX }: { translateX: number }) => {
return (
<div className="isolate flex h-full w-full items-center justify-center">
<div className="h-[30.5rem] w-[375px] relative grid grid-cols-1 grid-rows-1 overflow-hidden rounded-t-4xl bg-gray-950/10 outline outline-gray-950/10 dark:outline-white/10">
<div className="col-start-1 row-start-1">
<img
src="/DualThemeToggleSlider/img/light.png"
className="absolute inset-0"
alt="Light theme"
style={{ clip: `rect(0px, ${translateX - 1}px, 488px, 0px)` }}
/>
</div>
<div className="col-start-1 row-start-1">
<img
src="/DualThemeToggleSlider/img/dark.png"
className="absolute inset-0"
alt="Dark theme"
/>
</div>
</div>
</div>
);
};
export default ImageComparison;
This value controls how much of the light theme image is visible through the clip property:
style={{ clip: `rect(0px, ${translateX - 1}px, 488px, 0px)` }}
🔍 What does this do?
- clip allows us to define a rectangular visible area of the light image.
- As the translateX value changes (from dragging), the right edge of the visible rectangle also shifts.
- This creates a sliding mask effect, where the light image is revealed only up to the current translateX position.
- The dark theme image underneath remains fully visible, acting as the base layer.
We should have something like this:

Making the Divider Draggable
To bring interactivity to our slider, we implement custom drag logic using React's useRef and useState hooks.
import React, { useRef, useState } from "react";
import ImageComparison from "./components/ImageComparison";
const index = () => {
const isDragging = useRef(false);
const [translateX, setTranslateX] = useState(187.5);
const startX = useRef(0);
const containerWidth = 375;
const barWidth = 4;
function handleMouseDown(e) {
isDragging.current = true;
startX.current = e.clientX - translateX;
}
function handleMouseMove(e) {
if (isDragging.current) {
const newTranslateX = e.clientX - startX.current;
const constrainedX = Math.max(
0,
Math.min(newTranslateX, containerWidth - barWidth)
);
setTranslateX(constrainedX);
}
}
function handleMouseUp() {
isDragging.current = false;
}
const handleTouchStart = (e) => {
const touch = e.touches[0];
handleMouseDown(touch);
};
const handleTouchMove = (e) => {
const touch = e.touches[0];
handleMouseMove(touch);
};
const handleTouchEnd = () => {
handleMouseUp();
};
return (
<div className="bg-[#030712] p-5 flex justify-center items-center m-auto">
<div className="h-112 p-4 sm:p-8 relative overflow-hidden rounded-lg border border-gray-800 bg-gray-950/[2.5%] bg-[image:radial-gradient(var(--pattern-fg)_1px,_transparent_0)] bg-[size:10px_10px] bg-fixed [--pattern-fg:var(--color-white)]/10">
<div
className="w-full overflow-hidden relative"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Draggable Divider */}
<div
className="absolute inset-y-0 z-10 w-1 bg-sky-400 cursor-ew-resize"
style={{ transform: `translateX(${translateX}px)` }}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
></div>
<ImageComparison translateX={translateX} />
</div>
</div>
</div>
);
};
export default index;
Here's how the logic works step by step:
🧠 State and Refs Setup
const isDragging = useRef(false); // Tracks whether the user is currently dragging
const startX = useRef(0); // Stores initial drag start position
const containerWidth = 375; // Width of the image container
const barWidth = 4; // Width of the draggable bar
🟦 Drag Start (handleMouseDown)
function handleMouseDown(e) {
isDragging.current = true;
startX.current = e.clientX - translateX; // Calculate distance from divider to pointer
}
- When the user clicks (mousedown), we begin dragging.
- We calculate the offset between the initial click (clientX) and current position (translateX) — this makes dragging smooth and relative to the starting point.
🔁 While Dragging (handleMouseMove)
function handleMouseMove(e) {
if (isDragging.current) {
const newTranslateX = e.clientX - startX.current;
const constrainedX = Math.max(
0,
Math.min(newTranslateX, containerWidth - barWidth)
);
setTranslateX(constrainedX); // Update position within bounds
}
}
- As the user moves the mouse, we calculate the new position using clientX - startX.current.
- constrainedX makes sure the slider stays within the container bounds, preventing it from going out of view.
🛑 Drag End (handleMouseUp)
function handleMouseUp() {
isDragging.current = false;
}
- This function is used to stop the dragging behavior.
- It sets isDragging.current to false, which tells your logic that the user is no longer interacting with (dragging) the divider.
📱 Touch Support
const handleTouchStart = (e) => {
const touch = e.touches[0];
handleMouseDown(touch);
};
const handleTouchMove = (e) => {
const touch = e.touches[0];
handleMouseMove(touch);
};
const handleTouchEnd = () => {
handleMouseUp();
};
- Touch handlers reuse the same mouse logic by extracting the first touch point.
- This ensures the component works seamlessly on mobile devices.
🖱️ Event Handler Placement
<div
className="w-full overflow-hidden relative"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Draggable Divider */}
<div
className="absolute inset-y-0 z-10 w-1 bg-sky-400 cursor-ew-resize"
style={{ transform: `translateX(${translateX}px)` }}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
></div>
<ImageComparison translateX={translateX} />
</div>
- The outer container handles `onMouseMove` and `onMouseUp` to ensure smooth dragging even if the cursor moves outside the bar.
- The divider handles `onMouseDown` and `onTouchStart` to initiate dragging when clicked or touched.
- This separation ensures reliable interaction across the entire component area.
✅ Why This Approach Works
- The outer container ensures smooth dragging even if the mouse moves outside the bar.
- The inner bar ensures you can grab and drag directly from it.
- Touch support makes it work on mobile devices.
- Boundary constraints keep the slider within the visible area.
🎯 Final Summary
These event handlers work together to create a smooth dragging experience:
- Start dragging on mouse/touch down.
- Update position while dragging with boundary constraints.
- Stop dragging on mouse/touch up.
- Support both desktop and mobile interactions seamlessly.
See It In Action
🎉 Conclusion
You've successfully created an interactive Dual Theme Toggle Slider component! This component demonstrates advanced React patterns including custom drag logic, state management, and CSS clipping techniques. The result is a smooth, responsive UI element that works across desktop and mobile devices, perfectly recreating the elegant theme comparison experience from the Tailwind CSS landing page.