CoreUI is in beta - APIs and components may change.
CoreUI
Base Components

Tab Menu Horizontal

Provides a linear layout for making it easy to switch between sections or categories.


Installation

Install the following dependencies:

npm install @radix-ui/react-tabs @radix-ui/react-slot merge-refs

Create a use-tab-observer.ts file and paste the following code into it.

hooks/use-tab-observer.ts
// CoreUI useTabObserver v0.0.0import * 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();    };  }, [handleUpdate]);  return { mounted, listRef };}

Create a tab-menu-horizontal.tsx file and paste the following code into it.

components/ui/tab-menu-horizontal.tsx
// CoreUI 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,};

Examples

Overflowing Tabs

API Reference

This component is based on the Radix UI Tabs primitives. Refer to their documentation for the API reference.

On this page