fix(ui): tab component (#50183)

## What kind of change does this PR introduce?
bug fix on tab component + a small refactor

## What is the current behavior?

the active tab in an underline list gets border-b-2 while its siblings
get nothing, so it's 2px taller and its label sits higher than the rest
causing a smol layout shift within docs

## What is the new behavior?

- adds one absolute positioned bar that slides between tabs so nothing
moves
- favors track under an underline as an inset shadow vs a border

| state | preview |
| -------|------|
| before | <video
src="https://github.com/user-attachments/assets/b73820a6-2994-46d1-aa98-452681f0fef2"
/> |
| after | <video
src="https://github.com/user-attachments/assets/054cd67d-a50c-4aef-9340-a1e1d515047b"
/> |


## Test
1. visit [api
reference](https://docs-git-antlio-ui-components-tabs-supabase.vercel.app/docs/reference/javascript/installing?platform=npm&queryGroups=platform)
2. visit a [guide
](https://docs-git-antlio-ui-components-tabs-supabase.vercel.app/docs/guides/database/prisma)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added an animated tab indicator that follows the active tab and adapts
to layout changes.
  - Added support for customizing tab indicator styling.
- Respects reduced-motion preferences by disabling indicator transitions
when appropriate.

- **Style**
  - Streamlined tab borders, spacing, and underlined-tab styling.
- Improved tab panel spacing and standardized tab behavior in
documentation examples.
- Centralized easing behavior for smoother overlays, dropdowns, slides,
and panels.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
This commit is contained in:
Anthony LioandDanny White authored and GitHub committed 2026-09-16 00:00:27 +03:00
1 parent 6d08a747f1
commit 38f448b01c
9 files changed
+292 -68

No files matched your search

@@ -17,6 +17,7 @@ import {
cn,
Tabs,
TabsContent,
TabsIndicator,
TabsList,
TabsTrigger,
} from 'ui'
@@ -226,16 +227,26 @@ const components = {
Tabs: ({ className, ...props }: React.ComponentProps<typeof Tabs>) => (
<Tabs className={cn('relative mt-6 w-full', className)} {...props} />
),
TabsList: ({ className, ...props }: React.ComponentProps<typeof TabsList>) => (
TabsList: ({ className, children, ...props }: React.ComponentProps<typeof TabsList>) => (
<TabsList
className={cn('w-full justify-start rounded-none border-b bg-transparent p-0', className)}
className={cn(
'w-full justify-start rounded-none bg-transparent p-0',
'ps-4 -ms-4 [--tab-track-inset:--spacing(4)]',
className
)}
{...props}
/>
>
{children}
<TabsIndicator />
</TabsList>
),
TabsTrigger: ({ className, ...props }: React.ComponentProps<typeof TabsTrigger>) => (
<TabsTrigger
className={cn(
'relative h-9 rounded-none border-b-2 border-b-transparent bg-transparent px-4 pb-3 pt-2 font-semibold text-muted-foreground shadow-none transition-none data-[state=active]:border-b-primary data-[state=active]:text-foreground data-[state=active]:shadow-none',
'relative h-9 rounded-none bg-transparent px-4 pb-3 pt-2 font-semibold text-muted-foreground shadow-none transition-none data-[state=active]:text-foreground data-[state=active]:shadow-none',
// The first label lines up with the surrounding content, keeping its
// padding so the focus ring sits off the glyphs
'first:-ms-4',
className
)}
{...props}
@@ -10,6 +10,7 @@ import {
Label,
Tabs,
TabsContent,
TabsIndicator,
TabsList,
TabsTrigger,
} from 'ui'
@@ -20,6 +21,7 @@ export default function TabsDemo() {
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsIndicator />
</TabsList>
<TabsContent value="account">
<Card>
+3 -2
View File
@@ -11,7 +11,7 @@ import {
type ReactNode,
} from 'react'
// shadcn tabs from packages/ui/src/components/shadcn/ui/tabs.tsx
import { cn, copyToClipboard, Tabs, TabsContent, TabsList, TabsTrigger } from 'ui'
import { cn, copyToClipboard, Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from 'ui'
type PromptTitleProps = {
children: ReactNode
@@ -248,12 +248,13 @@ function PromptPanel({ children, className }: PromptPanelProps) {
const header = (
<div className="flex h-11 items-center justify-between border-b bg-surface-75 px-4">
{hasTabs ? (
<TabsList className="h-full gap-5 border-0">
<TabsList className="h-full gap-5 border-0 [--tab-track:transparent]">
{prompts.map((prompt) => (
<TabsTrigger key={prompt.value} value={prompt.value} className={tabTriggerClassName}>
<TabLabel icon={prompt.icon}>{prompt.title}</TabLabel>
</TabsTrigger>
))}
<TabsIndicator />
</TabsList>
) : (
<span
+38 -24
View File
@@ -1,7 +1,6 @@
'use client'
import { cva } from 'class-variance-authority'
import { Tabs as TabsPrimitive } from 'radix-ui'
import {
Children,
isValidElement,
@@ -13,7 +12,7 @@ import {
useState,
type KeyboardEvent,
} from 'react'
import { cn } from 'ui'
import { cn, TabsContent, TabsIndicator, TabsList, Tabs as TabsRoot, TabsTrigger } from 'ui'
import { useTocRerenderTrigger } from '../docs/GuidesMdx.state'
import { useStickyTabs, UseStickyTabsOptions } from './useStickyTabs'
@@ -39,10 +38,15 @@ export interface TabsProps {
export const tabsListVariants = cva(cn('flex'), {
variants: {
type: {
pills: 'space-x-1',
underlined: 'items-center border-b border-secondary',
cards: '',
'rounded-pills': 'flex-wrap gap-2',
pills: 'border-b-0 space-x-1',
// fix focus ring so it is not clipped
underlined: cn(
'relative items-center',
'ps-(--tab-lead) -ms-(--tab-lead) [--tab-track-inset:var(--tab-lead)]',
'[--tab-track:var(--border-secondary)]'
),
cards: 'border-b-0',
'rounded-pills': 'border-b-0 flex-wrap gap-2',
},
scrollable: {
true: 'overflow-auto whitespace-nowrap no-scrollbar mask-fadeout-right',
@@ -55,15 +59,18 @@ export const tabsListVariants = cva(cn('flex'), {
export const tabsTriggerListVariants = cva(
cn(
'relative cursor-pointer flex items-center space-x-2 text-center transition-colors focus-ring [&_img]:m-0 [&_img]:size-3.5 [&_svg]:size-3.5'
'relative cursor-pointer flex items-center gap-2 text-center transition-colors',
'focus-inset rounded-md',
'[&_img]:m-0 [&_img]:size-3.5 [&_svg]:size-3.5'
),
{
variants: {
type: {
pills: 'shadow-xs rounded-sm border',
underlined: 'text-foreground-lighter',
cards: '',
'rounded-pills': 'shadow-xs rounded-full',
pills: 'border-b-0 data-[state=active]:border-b-stronger shadow-xs rounded-sm border',
underlined: 'text-foreground-lighter data-[state=active]:shadow-none',
cards: 'border-b-0',
'rounded-pills':
'border-b-0 data-[state=active]:border-b-foreground shadow-xs rounded-full',
},
size: {
tiny: 'text-xs px-2.5 py-1',
@@ -94,8 +101,9 @@ export const tabsTriggerListVariants = cva(
{
type: 'underlined',
isActive: true,
className: '!text-foreground border-b-2 border-foreground',
className: '!text-foreground',
},
{ type: 'underlined', className: 'py-3 first:-ms-(--tab-lead)' },
{
type: 'rounded-pills',
isActive: true,
@@ -111,6 +119,14 @@ export const tabsTriggerListVariants = cva(
}
)
const TAB_LEAD: Record<NonNullable<TabsProps['size']>, string> = {
tiny: 'calc(var(--spacing) * 2.5)',
small: 'calc(var(--spacing) * 3)',
medium: 'calc(var(--spacing) * 4)',
large: 'calc(var(--spacing) * 4)',
xlarge: 'calc(var(--spacing) * 6)',
}
const isString = (maybeStr: unknown): maybeStr is string => typeof maybeStr === 'string'
export const Tabs = ({
@@ -208,12 +224,13 @@ export const Tabs = ({
]
)
return (
<TabsPrimitive.Root
<TabsRoot
value={activeTab}
className={cn('w-full justify-between space-y-4', baseClassNames)}
ref={observedRef}
>
<TabsPrimitive.List
<TabsList
style={{ '--tab-lead': TAB_LEAD[size] } as React.CSSProperties}
className={tabsListVariants({
type,
scrollable,
@@ -227,7 +244,7 @@ export const Tabs = ({
const isActive = activeTab === tab.props.id
return (
<TabsPrimitive.Trigger
<TabsTrigger
onKeyDown={(e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
@@ -242,13 +259,14 @@ export const Tabs = ({
{tab.props.icon}
<span>{tab.props.label}</span>
{tab.props.iconRight}
</TabsPrimitive.Trigger>
</TabsTrigger>
)
})}
{addOnAfter}
</TabsPrimitive.List>
{type === 'underlined' ? <TabsIndicator /> : null}
</TabsList>
{childrenArr}
</TabsPrimitive.Root>
</TabsRoot>
)
}
@@ -263,12 +281,8 @@ interface TabPanelProps {
export const TabPanel = ({ children, id, className }: TabPanelProps) => {
return (
<TabsPrimitive.Content
value={id}
className={cn('focus:outline-hidden transition-height', className)}
tabIndex={-1}
>
<TabsContent value={id} className={cn('mt-0 focus:outline-hidden', className)} tabIndex={-1}>
{children}
</TabsPrimitive.Content>
</TabsContent>
)
}
+15 -12
View File
@@ -10,36 +10,39 @@
*/
@theme {
--ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
--ease-move: cubic-bezier(0.87, 0, 0.13, 1);
--animate-fade-in: fadeIn 300ms both;
--animate-fade-out: fadeOut 300ms both;
/* may be unused — verify before pruning */
--animate-dropdown-content-show: overlayContentShow 100ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-dropdown-content-show: overlayContentShow 100ms var(--ease-enter);
/* may be unused — verify before pruning */
--animate-dropdown-content-hide: overlayContentHide 100ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-dropdown-content-hide: overlayContentHide 100ms var(--ease-enter);
--animate-overlay-show: overlayContentShow 300ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-overlay-hide: overlayContentHide 300ms cubic-bezier(0.16, 1, 0.3, 1);
--animate-overlay-show: overlayContentShow 300ms var(--ease-enter);
--animate-overlay-hide: overlayContentHide 300ms var(--ease-enter);
/* may be unused — verify before pruning */
--animate-fade-in-overlay-bg: fadeInOverlayBg 300ms;
/* may be unused — verify before pruning */
--animate-fade-out-overlay-bg: fadeOutOverlayBg 300ms;
--animate-slide-down: slideDown 300ms cubic-bezier(0.87, 0, 0.13, 1);
--animate-slide-up: slideUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
--animate-slide-down: slideDown 300ms var(--ease-move);
--animate-slide-up: slideUp 300ms var(--ease-move);
--animate-slide-down-normal: slideDownNormal 300ms cubic-bezier(0.87, 0, 0.13, 1);
--animate-slide-up-normal: slideUpNormal 300ms cubic-bezier(0.87, 0, 0.13, 1);
--animate-slide-down-normal: slideDownNormal 300ms var(--ease-move);
--animate-slide-up-normal: slideUpNormal 300ms var(--ease-move);
/* may be unused — verify before pruning */
--animate-panel-slide-left-out: panelSlideLeftOut 200ms cubic-bezier(0.87, 0, 0.13, 1);
--animate-panel-slide-left-out: panelSlideLeftOut 200ms var(--ease-move);
/* may be unused — verify before pruning */
--animate-panel-slide-left-in: panelSlideLeftIn 250ms cubic-bezier(0.87, 0, 0.13, 1);
--animate-panel-slide-left-in: panelSlideLeftIn 250ms var(--ease-move);
/* may be unused — verify before pruning */
--animate-panel-slide-right-out: panelSlideRightOut 200ms cubic-bezier(0.87, 0, 0.13, 1);
--animate-panel-slide-right-out: panelSlideRightOut 200ms var(--ease-move);
/* may be unused — verify before pruning */
--animate-panel-slide-right-in: panelSlideRightIn 250ms cubic-bezier(0.87, 0, 0.13, 1);
--animate-panel-slide-right-in: panelSlideRightIn 250ms var(--ease-move);
--animate-line-loading: lineLoading 1.8s infinite;
--animate-line-loading-slower: lineLoading 2.3s infinite;
+1
View File
@@ -113,6 +113,7 @@ export * from './src/components/ShadowScrollArea'
export * from './src/components/shadcn/ui/collapsible'
export * from './src/components/shadcn/ui/tabs'
export * from './src/components/shadcn/ui/useTabIndicator'
export * from './src/components/shadcn/ui/tooltip'
@@ -0,0 +1,85 @@
import { render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from './tabs'
const renderTabs = ({ indicator = false } = {}) =>
render(
<Tabs defaultValue="one">
<TabsList>
<TabsTrigger value="one">One</TabsTrigger>
<TabsTrigger value="two">Two</TabsTrigger>
{indicator ? <TabsIndicator /> : null}
</TabsList>
<TabsContent value="one">First</TabsContent>
<TabsContent value="two">Second</TabsContent>
</Tabs>
)
describe('Tabs', () => {
it('borders each trigger when no indicator is rendered', () => {
const { container } = renderTabs()
expect(screen.getByRole('tab', { name: 'One' })).toHaveClass('border-b-2')
expect(container.querySelector('[data-tab-indicator]')).toBeNull()
})
it('measures the active tab only for lists that render an indicator', async () => {
const withoutIndicator = renderTabs().container.querySelector<HTMLElement>('[role="tablist"]')
const withIndicator = renderTabs({ indicator: true }).container.querySelector<HTMLElement>(
'[role="tablist"]'
)
await waitFor(() => expect(withIndicator?.dataset.tabIndicatorReady).toBe(''))
expect(withoutIndicator?.dataset.tabIndicatorReady).toBeUndefined()
})
it('hides the underline when no trigger is active', async () => {
const { container, rerender } = render(
<Tabs value="one">
<TabsList>
<TabsTrigger value="one">One</TabsTrigger>
<TabsIndicator />
</TabsList>
<TabsContent value="one">First</TabsContent>
</Tabs>
)
const list = container.querySelector<HTMLElement>('[role="tablist"]')
await waitFor(() => expect(list?.dataset.tabIndicatorReady).toBe(''))
rerender(
<Tabs value="gone">
<TabsList>
<TabsTrigger value="one">One</TabsTrigger>
<TabsIndicator />
</TabsList>
<TabsContent value="one">First</TabsContent>
</Tabs>
)
await waitFor(() => expect(list?.dataset.tabIndicatorReady).toBeUndefined())
})
it('renders the first trigger as the first element in the list', () => {
const { container } = renderTabs({ indicator: true })
expect(container.querySelector('[role="tablist"]')?.firstElementChild).toBe(
screen.getByRole('tab', { name: 'One' })
)
})
it('passes a caller ref through to the list element', () => {
const ref = { current: null as HTMLDivElement | null }
render(
<Tabs defaultValue="one">
<TabsList ref={ref}>
<TabsTrigger value="one">One</TabsTrigger>
</TabsList>
<TabsContent value="one">First</TabsContent>
</Tabs>
)
expect(ref.current).toBe(screen.getByRole('tablist'))
})
})
+67 -26
View File
@@ -1,46 +1,87 @@
'use client'
import { Tabs as TabsPrimitive } from 'radix-ui'
import * as React from 'react'
import { useRef, type ComponentPropsWithRef } from 'react'
import { cn } from '../../../lib/utils/cn'
import { useTabIndicator } from './useTabIndicator'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn('flex items-center border-b', className)}
const trackClasses = cn(
'has-[[data-tab-indicator]]:border-b-0',
'has-[[data-tab-indicator]]:after:pointer-events-none has-[[data-tab-indicator]]:after:absolute',
'has-[[data-tab-indicator]]:after:bottom-0 has-[[data-tab-indicator]]:after:h-px',
'has-[[data-tab-indicator]]:after:left-[var(--tab-track-inset,0px)]',
'has-[[data-tab-indicator]]:after:right-0',
'has-[[data-tab-indicator]]:after:bg-[var(--tab-track,var(--border-default))]'
)
const TabsList = ({
className,
children,
ref,
...props
}: ComponentPropsWithRef<typeof TabsPrimitive.List>) => {
const listRef = useRef<HTMLDivElement>(null)
useTabIndicator(listRef)
return (
<TabsPrimitive.List
ref={(node) => {
listRef.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
}}
className={cn('group/list relative flex items-center border-b', trackClasses, className)}
{...props}
>
{children}
</TabsPrimitive.List>
)
}
const TabsIndicator = ({ className, ...props }: ComponentPropsWithRef<'span'>) => (
<span
aria-hidden
data-tab-indicator
className={cn(
'pointer-events-none absolute bottom-0 left-0 h-px bg-foreground',
'w-[var(--active-tab-width,0)] translate-x-[var(--active-tab-left,0)]',
'transition-none opacity-0',
'group-data-[tab-indicator-ready]/list:opacity-100',
'group-data-[tab-indicator-ready]/list:transition-[translate,width]',
'group-data-[tab-indicator-ready]/list:duration-[250ms]',
'group-data-[tab-indicator-ready]/list:ease-move',
'motion-reduce:transition-none',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
)
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
const TabsTrigger = ({
className,
...props
}: ComponentPropsWithRef<typeof TabsPrimitive.Trigger>) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex cursor-pointer items-center justify-center whitespace-nowrap py-1.5 text-sm transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground data-[state=active]:shadow-xs text-foreground-lighter hover:text-foreground data-[state=active]:border-foreground border-b-2 border-transparent',
'inline-flex cursor-pointer items-center justify-center whitespace-nowrap py-1.5 text-sm transition-colors disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground data-[state=active]:shadow-xs text-foreground-lighter hover:text-foreground',
'focus-inset',
'border-b-2 border-b-transparent data-[state=active]:border-b-foreground',
'group-has-[[data-tab-indicator]]/list:border-b-0',
'group',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
)
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content ref={ref} className={cn('mt-4 focus-ring', className)} {...props} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
const TabsContent = ({
className,
...props
}: ComponentPropsWithRef<typeof TabsPrimitive.Content>) => (
<TabsPrimitive.Content className={cn('mt-4 focus-ring', className)} {...props} />
)
export { Tabs, TabsContent, TabsList, TabsTrigger }
export { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger }
@@ -0,0 +1,66 @@
'use client'
import { useLayoutEffect, type RefObject } from 'react'
export const useTabIndicator = (listRef: RefObject<HTMLElement | null>) => {
useLayoutEffect(() => {
const list = listRef.current
if (!list || !list.querySelector(':scope > [data-tab-indicator]')) return
let cancelled = false
const measure = () => {
const trigger = list.querySelector<HTMLElement>(':scope > [role="tab"][data-state="active"]')
if (cancelled) return
if (!trigger) {
delete list.dataset.tabIndicatorReady
return
}
const styles = getComputedStyle(trigger)
const paddingLeft = parseFloat(styles.paddingLeft) || 0
const paddingRight = parseFloat(styles.paddingRight) || 0
list.style.setProperty('--active-tab-left', `${trigger.offsetLeft + paddingLeft}px`)
list.style.setProperty(
'--active-tab-width',
`${trigger.offsetWidth - paddingLeft - paddingRight}px`
)
if (list.dataset.tabIndicatorReady === undefined) {
// makes the bar already sitting on the active tab rather than sliding in
requestAnimationFrame(() => {
if (!cancelled) list.dataset.tabIndicatorReady = ''
})
}
}
const resizes = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
const sync = () => {
resizes?.disconnect()
resizes?.observe(list)
Array.from(list.children).forEach((child) => resizes?.observe(child))
measure()
}
sync()
const mutations =
typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(sync)
mutations?.observe(list, {
attributes: true,
attributeFilter: ['data-state'],
subtree: true,
childList: true,
})
// label widths shift when webfonts land
document.fonts?.ready.then(measure).catch(() => {})
return () => {
cancelled = true
mutations?.disconnect()
resizes?.disconnect()
delete list.dataset.tabIndicatorReady
}
}, [listRef])
}