Segmented Control
A sharp-edged, animated segmented control.
Before diving into this component, I want to start by explaining what a "Segmented Control Component" is.
What is a Segmented Control?
A segmented control is a UI element that allows a user to select one option from two or more choices presented horizontally. Each segment typically acts like a button, and the active segment is highlighted with a distinct background or border. These controls are used in situations requiring a quick switch between a few options; in other words, they let the user pick a single choice from a closely related set of options.
The "Switcher/Toggle" vs "Segmented Control" difference
At this point, the question "Isn't this the same as a Switcher/Toggle?" might come to mind. While these two terms are often used interchangeably, there is a subtle difference:
- Switcher / Toggle — Generally used to turn a single setting "On" or "Off".
- Segmented Control — As mentioned in the definition, it allows you to select only one option from a group (e.g., A, B, or C).
So yes, the [ Light ] and [ Dark ] theme changer I designed is technically a "Segmented Control" component with two segments. Since its job is to "change the theme," calling it a "Theme Switcher" also accurately describes its function.
Structure & Implementation notes
To be honest, for this component built with Next.js, Tailwind, and Framer Motion, there are a few important and critical points. It wouldn't be wrong to say I coded this component three times. I advanced and tried to optimize the code step by step. In the final step, the current version, it dynamically calculates the width of the labels and determines the position and width of the selector. If you wish, you can build a simpler structure with fewer options and static widths, like a switcher/toggle. Let's start examining the code piece by piece.
import { useState, useRef, useLayoutEffect, useCallback } from "react";
import { motion } from "framer-motion";
import clsx from "clsx";
The first thing to notice here is the use of useLayoutEffect. Unlike useEffect, which runs after the paint process is complete, we use useLayoutEffect because we want to get the position and width from the DOM just before the browser renders it to the screen. In other words, we are intervening "right before the screen is drawn, when the DOM is fully ready, but before the user has seen it."
interface SegmentedControlProps {
options?: string[];
className?: string;
onValueChange?: (value: string) => void;
}
export default function SegmentedControl({
options = ["Easy", "Medium", "Hard"],
className,
onValueChange,
}: SegmentedControlProps) {
const [selectedOption, setSelectedOption] = useState(options[0]);
const [isAnimating, setIsAnimating] = useState(false);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [selectorStyle, setSelectorStyle] = useState({ left: 0, width: 0 });
We receive our props and define our states. We use useRef to hold the references to the buttons in an array.
const handleSelect = useCallback(
(nextOption: string) => {
if (nextOption !== activeOption && !isAnimating) {
setIsAnimating(true);
setActiveOption(nextOption);
}
},
[activeOption, isAnimating]
);
useLayoutEffect(() => {
const selectedIndex = options.indexOf(selectedOption);
const selectedButton = buttonRefs.current[selectedIndex];
if (selectedButton) {
const { offsetLeft, offsetWidth } = selectedButton;
setSelectorStyle((prev) => {
if (prev.left === offsetLeft && prev.width === offsetWidth) return prev;
return { left: offsetLeft, width: offsetWidth };
});
}
}, [selectedOption, options]);
We prevent unnecessary re-renders with useCallback.
useLayoutEffect runs its effects just before the browser paints, unlike useEffect. This allows us to measure the offsetLeft and offsetWidth of the selected button before the user sees the screen and set the correct position and width using setSelectorStyle.
You can find more details at these links for offsetLeft and offsetWidth
return (
<div
className={clsx(
"relative inline-flex h-[44px] items-center bg-tertiary p-1 dark:bg-secondary",
"border border-secondary dark:border-secondary",
className
)}
data-name="segmented-control"
>
{/* Animated Selector */}
<motion.div
className="absolute top-1/2 z-10 h-[36px] -translate-y-1/2 overflow-hidden bg-primary dark:bg-[#111]"
initial={false}
animate={selectorStyle}
transition={{
delay: isAnimating ? 0.3 : 0,
duration: isAnimating ? 0.4 : 0,
ease: [0.4, 0, 0.2, 1],
}}
onAnimationComplete={() => setIsAnimating(false)}
>
We've come to one of the critical points in our component's design. We are using a layered structure. The Animated Selector, created with motion.div, has a z-10 class and is the second layer from the top. It creates the button background based on the selectorStyle state we saw earlier and handles the animation, creating a smooth sliding effect. The overflow-hidden class isn't strictly necessary, but there's no harm in keeping it as a defensive programming practice.
onAnimationComplete={() => setIsAnimating(false)} : The onAnimationComplete event triggers when the animation finishes, and we call setIsAnimating(false) to set the animation state to false.
<div className="absolute left-0 right-0 top-0 h-px bg-sixth dark:bg-sixth" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-sixth dark:bg-sixth" />
{/* Animated side borders */}
{["left", "right"].map((side) => (
<div key={side} className={`absolute ${side}-0 bottom-0 top-0 w-px`}>
{["top", "bottom"].map((pos) => (
<motion.div
key={`${side}-${pos}`}
className={`absolute ${pos}-0 left-0 h-1/2 w-full bg-sixth dark:bg-sixth ${
pos === "top" ? "origin-top" : "origin-bottom"
}`}
initial={{ scaleY: 1 }}
animate={{ scaleY: isAnimating ? 0 : 1 }}
transition={{
delay: isAnimating ? 0 : 0.2,
duration: 0.3,
ease: [0.4, 0, 0.2, 1],
}}
/>
))}
</div>
))}
</motion.div>
We've arrived at the most important part affecting our component's animation. Before I started coding this, while thinking about how it could be done, the idea of using "faux borders" came to mind, which is what everyone thinks of. These are 1px <div>'s (w-p),(h-px) that take the place of actual borders. Our job would have been much harder without using faux borders.
In the first two lines, we create the static top and bottom faux borders. The "Animated side borders" part is what gives the component its flair. To get this animation effect, we need to split the component's left and right faux borders into two equal parts. We apply this split using the inner loop. With the origin-top/origin-bottom classes, we tell the borders which direction to shrink towards.
{/* Option Buttons */}
{options.map((option, index) => (
<button
key={option}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => handleSelect(option)}
className={clsx(
"relative z-20 h-full cursor-pointer bg-transparent px-4 text-[16px] ",
option === selectedOption ? "text-primary" : "opacity-70 hover:opacity-100"
)}
aria-pressed={option === selectedOption}
>
{option}
</button>
))}
</div>
);
}
In this piece, we hold the button's reference with useRef and create the dynamically incoming labels and their clickable areas.
This component demonstrates how precise layout measurement and creative animation techniques can elevate a simple UI element. By using useLayoutEffect to read offsetLeft and offsetWidth before paint, the selector moves smoothly without visual delay. The distinctive motion comes from animating “faux borders” —1px divs split into top and bottom halves—whose scaleY transitions create a refined disappearing and reappearing effect.
With this logic in mind, feel free to modify and enhance the component to fit your needs.