Segmented Control
Categorystyled
ArchitectureReact
Client
Component
Dependencies
Usage
import { useState } from "react";
import { SegmentedControl } from "@/components/react/styled/segmentedControl";
import { DemoWrapper } from "@/demos/react/demoWrapper";
import {
TruckIcon,
CubeIcon,
CubeTransparentIcon,
FingerPrintIcon,
} from "@heroicons/react/24/solid";
const internalLinks = [
{
children: "Setup",
href: "/setup",
id: "setup",
iconLeft: <TruckIcon className="h-4 w-4" />,
},
{
children: "Components",
href: "/components",
id: "components",
iconLeft: <CubeIcon className="h-4 w-4" />,
},
{
children: "Hooks",
href: "/hooks",
id: "hooks",
iconLeft: <CubeTransparentIcon className="h-4 w-4" />,
},
{
children: "About",
href: "/about",
id: "about",
iconLeft: <FingerPrintIcon className="h-4 w-4" />,
},
];
export const SegmentedControlDemo = ({ code }: { code: string }) => {
const [selected, setSelected] = useState("setup");
return (
<DemoWrapper code={code}>
<SegmentedControl
selected={selected}
onSelected={setSelected}
className="p-0"
keepBg={false}
items={internalLinks}
/>
</DemoWrapper>
);
};
Install Instructions
Verifying dependencies
Make sure the following dependencies are satisfied:
- button
Copy and paste the code into that file
import {
type ComponentPropsWithoutRef,
type Dispatch,
type ElementType,
type ReactNode,
type SetStateAction,
createElement,
useCallback,
useRef,
} from "react";
// Sub Components
import { Button, type ButtonProps } from "@/components/react/styled/button";
// Third Party Utils
import { twMerge } from "tailwind-merge";
import { cva } from "class-variance-authority";
/** Applies a series of styles */
const applyStyles = (el: HTMLElement, values: [string, string][]) =>
values.forEach((item) => el.style.setProperty(item[0], item[1]));
/** Applies a series of styles */
const removeStyles = (el: HTMLElement, values: string[]) =>
values.forEach((item) => el.style.removeProperty(item));
//----- Internal Types -----//
type Item<T extends ElementType = "button"> =
| { id: string; children: ReactNode }
| Omit<
ButtonProps<T>,
| "id"
| "onClick"
| "onPointerEnter"
| "key"
| "variant"
| "className"
| "component"
>;
//----- Utils -----//
type MatchType = "complete" | "part-of-id" | "part-of-selected";
const isActive = ({
id,
selected,
matchType,
}: {
id: string;
selected?: string;
matchType: MatchType;
}) => {
if (typeof selected === "undefined") return false;
switch (matchType) {
case "complete":
return id === selected;
case "part-of-selected":
return selected.includes(id);
case "part-of-id":
return id.includes(selected);
}
};
//----- Styles -----//
const SegmentedControlClassNames = cva(
[
"flex",
"p-2",
"gap-2",
"rounded-3xl",
"w-fit",
"after:bg-[var(--bg-color,_transparent)]",
"after:rounded-3xl",
"after:inset-0",
"after:block",
"after:absolute",
"after:-z-20",
"relative",
"md:motion-safe:before:dark:bg-primary-400/25",
"md:motion-safe:before:bg-primary-700/20",
"md:motion-safe:before:left-0",
"md:motion-safe:before:top-0",
"md:motion-safe:before:h-full",
"md:motion-safe:before:block",
"md:motion-safe:before:absolute",
"md:motion-safe:before:[clip-path:_inset(var(--start-y,_0)_var(--end-x,_50%)_var(--end-y,_0)_var(--start-x,_50%)_round_theme(borderRadius.3xl))]",
"md:motion-safe:before:rounded-[var(--radius,_0)]",
"md:motion-safe:before:origin-left",
"md:motion-safe:before:transition-all",
"md:motion-safe:before:pointer-events-none",
"md:motion-safe:before:w-full",
"md:motion-safe:before:opacity-[var(--opacity,_0)]",
"md:motion-safe:before:-z-10",
"md:motion-safe:before:[transition-duration:_var(--duration,_150ms)]",
],
{
variants: {
keepBg: {
true: [
"dark:[--bg-color:_theme(colors.neutral.900)]",
"[--bg-color:_theme(colors.neutral.100)]",
],
false: [],
},
},
defaultVariants: {
keepBg: true,
},
}
);
//----- Exported Types -----//
export interface SegmentedControlProps<T extends ElementType = "button">
extends Omit<
ComponentPropsWithoutRef<"div">,
"children" | "onPointerLeave" | "onPointerCancel"
> {
items: Item<T>[];
selected?: undefined | string;
onSelected?:
| undefined
| Dispatch<SetStateAction<string>>
| ((v: string) => void);
buttonComponents?: T;
keepBg?: boolean;
matchType?: MatchType;
}
//----- Main Component -----//
export const SegmentedControl = <T extends ElementType = "button">({
items,
selected,
onSelected,
className,
buttonComponents,
keepBg = true,
matchType = "complete",
...props
}: SegmentedControlProps<T>) => {
const segmentedControlWrapperRef = useRef<HTMLDivElement | null>(null);
const initialHoverTarget = useRef(true);
/** Starts the button hovering effect */
const onHover = useCallback((id: string) => {
const wrapper = segmentedControlWrapperRef.current;
if (wrapper === null) return;
let targetEl: HTMLButtonElement | null = null;
// Finding the target el
wrapper.childNodes.forEach((el) => {
if (
(el as HTMLElement).hasAttribute("id") &&
`segmentedControl-${id}` === (el as HTMLElement).id
) {
targetEl = el as HTMLButtonElement;
}
});
// Verifying the target el
if (targetEl === null) return;
// Getting the positioning of elements
const {
width: itemWidth,
top: itemTop,
bottom: itemBottom,
left: itemLeft,
right: itemRight,
} = (targetEl as HTMLButtonElement).getBoundingClientRect();
const {
width: wrapperWidth,
height: wrapperHeight,
top: wrapperTop,
bottom: wrapperBottom,
left: wrapperLeft,
right: wrapperRight,
} = wrapper.getBoundingClientRect();
// Calculating the positions
const startX = ((itemLeft - wrapperLeft) / wrapperWidth) * 100;
const endX = (Math.abs(itemRight - wrapperRight) / wrapperWidth) * 100;
const startY = ((itemTop - wrapperTop) / wrapperHeight) * 100;
const endY = (Math.abs(wrapperBottom - itemBottom) / wrapperHeight) * 100;
// Incase its the initial hover:
// - Positioning the hover element in the center of target element
// - Specifying the top and the bottom
// - Modifying the `initialHoverTarget`
if (initialHoverTarget.current) {
const itemCenter = itemWidth / 2 + itemLeft;
const centerX = ((itemCenter - wrapperLeft) / wrapperWidth) * 100;
applyStyles(wrapper, [
["--start-x", centerX - 2 + "%"],
["--end-x", 98 - centerX + "%"],
["--start-y", startY + "%"],
["--end-y", endY + "%"],
["--duration", "0s"],
]);
initialHoverTarget.current = false;
}
// Modifying the hover element to the size and the position of target element
setTimeout(() => {
removeStyles(wrapper, ["--duration"]);
applyStyles(wrapper, [
["--start-x", startX + "%"],
["--end-x", endX + "%"],
["--opacity", "1"],
]);
}, 1);
}, []);
/** Ends the button hovering effect */
const onHoverEnd = useCallback(() => {
const wrapper = segmentedControlWrapperRef.current;
if (wrapper === null) return;
wrapper.style.removeProperty("--opacity");
initialHoverTarget.current = true;
}, []);
return (
<div
onPointerLeave={onHoverEnd}
onPointerCancel={onHoverEnd}
ref={segmentedControlWrapperRef}
className={twMerge(SegmentedControlClassNames({ keepBg }), className)}
{...props}
>
{items.map(({ id, children, ...props }) => {
//-- Checking if the item is the active item --//
const state = isActive({
id,
selected: selected ? selected : "",
matchType,
});
return createElement(
Button,
{
key: id,
component: buttonComponents,
onPointerEnter: onHover.bind(null, id),
variant: state ? "subtle" : "ghost",
["data-active"]: state,
onClick: onSelected && onSelected.bind(null, id),
className: [
"!rounded-[calc(theme(borderRadius.3xl)_+_theme(space.2))]",
"hover:dark:bg-primary-300/25",
"hover:bg-primary-300",
"md:motion-safe:hover:bg-transparent",
"md:motion-safe:hover:bg-transparent",
].join(" "),
id: `segmentedControl-${id}`,
...props,
},
children
);
})}
</div>
);
};