Base Components
Tab Menu Horizontal
Provides a linear layout for making it easy to switch between sections or categories.
Installation
Install the following dependencies:
terminal
npm install @radix-ui/react-tabs
Create a use-tab-observer.ts
file and paste the following code into it.
/hooks/use-tab-observer.ts
// AlignUI useTabObserver v0.0.0
import * as React from 'react';
interface TabObserverOptions {
onActiveTabChange?: (index: number, element: HTMLElement) => void;
}
export function useTabObserver({ onActiveTabChange }: TabObserverOptions = {}) {
const [mounted, setMounted] = React.useState(false);
const listRef = React.useRef<HTMLDivElement>(null);
const onActiveTabChangeRef = React.useRef(onActiveTabChange);
React.useEffect(() => {
onActiveTabChangeRef.current = onActiveTabChange;
}, [onActiveTabChange]);
const handleUpdate = React.useCallback(() => {
if (listRef.current) {
const tabs = listRef.current.querySelectorAll('[role="tab"]');
tabs.forEach((el, i) => {
if (el.getAttribute('data-state') === 'active') {
onActiveTabChangeRef.current?.(i, el as HTMLElement);
}
});
}
}, []);
React.useEffect(() => {
setMounted(true);
const resizeObserver = new ResizeObserver(handleUpdate);
const mutationObserver = new MutationObserver(handleUpdate);
if (listRef.current) {
resizeObserver.observe(listRef.current);
mutationObserver.observe(listRef.current, {
childList: true,
subtree: true,
attributes: true,
});
}
handleUpdate();
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, []);
return { mounted, listRef };
}
Create a tab-menu-horizontal.tsx
file and paste the following code into it.
/components/ui/tab-menu-horizontal.tsx
// AlignUI TabMenuHorizontal v0.0.0
'use client';
import * as React from 'react';
import { Slottable } from '@radix-ui/react-slot';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import mergeRefs from 'merge-refs';
import { useTabObserver } from '@/hooks/use-tab-observer';
import { cn } from '@/utils/cn';
import type { PolymorphicComponentProps } from '@/utils/polymorphic';
const TabMenuHorizontalContent = TabsPrimitive.Content;
TabMenuHorizontalContent.displayName = 'TabMenuHorizontalContent';
const TabMenuHorizontalRoot = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Root>,
Omit<React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>, 'orientation'>
>(({ className, ...rest }, forwardedRef) => {
return (
<TabsPrimitive.Root
ref={forwardedRef}
orientation='horizontal'
className={cn('w-full', className)}
{...rest}
/>
);
});
TabMenuHorizontalRoot.displayName = 'TabMenuHorizontalRoot';
const TabMenuHorizontalList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
wrapperClassName?: string;
}
>(({ children, className, wrapperClassName, ...rest }, forwardedRef) => {
const [lineStyle, setLineStyle] = React.useState({ width: 0, left: 0 });
const listWrapperRef = React.useRef<HTMLDivElement>(null);
const { mounted, listRef } = useTabObserver({
onActiveTabChange: (_, activeTab) => {
const { offsetWidth: width, offsetLeft: left } = activeTab;
setLineStyle({ width, left });
const listWrapper = listWrapperRef.current;
if (listWrapper) {
const containerWidth = listWrapper.clientWidth;
const scrollPosition = left - containerWidth / 2 + width / 2;
listWrapper.scrollTo({
left: scrollPosition,
behavior: 'smooth',
});
}
},
});
return (
<div
ref={listWrapperRef}
className={cn(
'relative grid overflow-x-auto overflow-y-hidden overscroll-contain',
wrapperClassName,
)}
>
<TabsPrimitive.List
ref={mergeRefs(forwardedRef, listRef)}
className={cn(
'group/tab-list relative flex h-12 items-center gap-6 whitespace-nowrap border-y border-stroke-soft-200',
className,
)}
{...rest}
>
<Slottable>{children}</Slottable>
{/* Floating Bg */}
<div
className={cn(
'absolute -bottom-px left-0 h-0.5 bg-primary-base opacity-0 transition-all duration-300 group-has-[[data-state=active]]/tab-list:opacity-100',
{
hidden: !mounted,
},
)}
style={{
transform: `translate3d(${lineStyle.left}px, 0, 0)`,
width: `${lineStyle.width}px`,
transitionTimingFunction: 'cubic-bezier(0.65, 0, 0.35, 1)',
}}
aria-hidden='true'
/>
</TabsPrimitive.List>
</div>
);
});
TabMenuHorizontalList.displayName = 'TabMenuHorizontalList';
const TabMenuHorizontalTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...rest }, forwardedRef) => {
return (
<TabsPrimitive.Trigger
ref={forwardedRef}
className={cn(
// base
'group/tab-item h-12 py-3.5 text-label-sm text-text-sub-600 outline-none',
'flex items-center justify-center gap-1.5',
'transition duration-200 ease-out',
// focus
'focus:outline-none',
// active
'data-[state=active]:text-text-strong-950',
className,
)}
{...rest}
/>
);
});
TabMenuHorizontalTrigger.displayName = 'TabMenuHorizontalTrigger';
function TabMenuHorizontalIcon<T extends React.ElementType>({
className,
as,
...rest
}: PolymorphicComponentProps<T>) {
const Component = as || 'div';
return (
<Component
className={cn(
// base
'size-5 text-text-sub-600',
'transition duration-200 ease-out',
// active
'group-data-[state=active]/tab-item:text-primary-base',
className,
)}
{...rest}
/>
);
}
TabMenuHorizontalIcon.displayName = 'TabsHorizontalIcon';
function TabMenuHorizontalArrowIcon<T extends React.ElementType>({
className,
as,
...rest
}: PolymorphicComponentProps<T, React.HTMLAttributes<HTMLDivElement>>) {
const Component = as || 'div';
return (
<Component
className={cn('size-5 text-text-sub-600', className)}
{...rest}
/>
);
}
TabMenuHorizontalArrowIcon.displayName = 'TabsHorizontalArrow';
export {
TabMenuHorizontalRoot as Root,
TabMenuHorizontalList as List,
TabMenuHorizontalTrigger as Trigger,
TabMenuHorizontalIcon as Icon,
TabMenuHorizontalArrowIcon as ArrowIcon,
TabMenuHorizontalContent as Content,
};
Update the import paths to match your project setup.
Examples
Overflowing Tabs
API Reference
This component is based on the Radix UI Tabs primitives. Refer to their documentation for the API reference.
© 2024 AlignUI Design System. All rights reserved.
ON THIS PAGE