Tabs
A set of layered sections of content—known as tab panels—that are displayed one at a time.
Javascript added to bundle: 500B
Example
Content for Example 1 tab. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Content for Example 2 tab. Lorem Ipsum is simply dummy text of the printing and typesetting industry.
Content for Example 2 tab. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Content for Example 2 tab. Lorem Ipsum is simply dummy text of the printing and typesetting industry.
Content for Example 2 tab. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Content for Example 2 tab. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Content for Example 2 tab. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Content for Example 2 tab. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Content for Example 2 tab. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Content for Example 2 tab. Lorem Ipsum is simply dummy text of the printing and typesetting industry.
Code
import cn from '@/utils/cn'
import { applyPropsToChildrenOfType } from '@/utils/manipulateReactComponents'
import { cva, type VariantProps } from 'class-variance-authority'
import React, { useId, type CSSProperties, type ReactNode } from 'react'
import TabTrigger from './TabTrigger'
TabTrigger.displayName = 'TabTrigger'
// const todoStyles = {
// wrapper: '[&>label]:basis-[calc(100%/var(--count))] auto-cols-fr',
// after: 'after:w-[calc(100%/var(--count))]'
// }
const styles = {
wrapper: 'tabs my-3 w-full relative flex flex-col max-w-full h-fit [--duration:.3s]',
after:
'after:pointer-events-none after:h-9 after:absolute after:bottom-0 after:left-0 after:top-0 after:translate-x-[calc(var(--active,0)*100%)] after:rounded-md after:bg-zinc-700 after:mix-blend-difference after:outline after:outline-2 after:outline-transparent after:transition-[transform,outline] after:duration-200 after:content-[""]',
tabContent: 'tab-content flex overflow-hidden py-2',
tabHeader: 'tab-header flex w-fit flex-wrap gap-1 rounded-md bg-zinc-800 p-0.5 text-[15px]'
}
const tabPanelBase = cva(
[
'grid',
'w-full',
'overflow-hidden',
'flex-shrink-0',
'[&.active]:grid-rows-[var(--grid-template-value,1fr)]',
'transition-[--animation-properties]',
'translate-x-[calc(-100%*var(--active))]',
'duration-[--duration]',
'ease-in-out',
'[&>*]:duration-[--duration]',
'[&>*]:transition-all',
'[&>*]:ease-in-out'
],
{
variants: {
autoHeight: {
true: 'grid-rows-[var(--grid-template-value,0fr)] [&>*]:min-h-0'
},
animateHeight: {
false: '[&>*]:min-h-0 [&>*]:transition-all [&>*]:duration-[--duration]'
},
animation: {
'slide-fade':
'[&.active>*]:visible [&.active>*]:opacity-100 [&.active]:!visible [&.active]:opacity-100 [&>*]:invisible [&>*]:opacity-0 [&>*]:transition-all [&>*]:duration-[--duration]',
fade: '[&.active>*]:visible [&.active>*]:opacity-100 [&.active]:visible [&.active]:opacity-100 [&>*]:invisible [&>*]:opacity-0',
slide: '',
none: 'transition-none [&>*]:transition-none [&>*]:duration-0 [&>*]:opacity-100'
}
}
}
)
type TabPanelProps = VariantProps<typeof tabPanelBase> & {
id?: string
children: ReactNode
className?: string
index?: number
}
// prettier-ignore
const TabPanel: React.FC<TabPanelProps> = ({
children,
index = 0,
className,
animation = 'slide-fade',
autoHeight = true,
animateHeight = true
}) => {
let animationProperties = []
if (animateHeight) animationProperties.push('grid-template-rows')
if (String(animation).includes('fade')) animationProperties.push('opacity')
if (String(animation).includes('slide')) animationProperties.push('transform')
return (
<div
style={
{
'--animation-properties': animationProperties.join(',')
} as CSSProperties
}
className={cn(
tabPanelBase({
autoHeight,
animateHeight,
animation
}),
index === 0 && 'active',
)}>
<div>
<div className={cn('height-div grid', className)}>
{children}
</div>
</div>
</div>
)
}
TabPanel.displayName = 'TabPanel'
type TabsContentProps = {
children: ReactNode
className?: string
id?: string
}
const TabsContent: React.FC<TabsContentProps> = ({ children, className, id }) => {
const childrenWithExtraProps = applyPropsToChildrenOfType(children, { id }, TabPanel, { includeIndex: true })
return <div className={cn('tab-content flex overflow-hidden py-2 transition-all duration-[--duration] ease-in-out', className)}>{childrenWithExtraProps}</div>
}
TabsContent.displayName = 'TabsContent'
type TabHeaderProps = {
children: ReactNode
id?: string
className?: string
}
const TabHeader: React.FC<TabHeaderProps> = ({ children, id, className }) => {
const childrenWithExtraProps = applyPropsToChildrenOfType(children, { id }, TabTrigger, { includeIndex: true })
return <div className={cn(styles.tabHeader, className)}>{childrenWithExtraProps}</div>
}
TabHeader.displayName = 'TabHeader'
type TabsProps = {
children: ReactNode
className?: string
} & TabPanelProps
const Tabs: React.FC<TabsProps> = ({ children, className, animation, animateHeight, autoHeight }) => {
const id = useId()
const enhancedChildren = applyPropsToChildrenOfType(children, { id }, TabHeader)
const TabsContentWithEnhancedChildren = React.Children.map(enhancedChildren, (child) => {
if (React.isValidElement(child) && child.type === TabsContent) {
const enhancedTabContentChildren = applyPropsToChildrenOfType(child.props.children, { animation, animateHeight, autoHeight }, TabPanel)
return React.cloneElement(child, {}, enhancedTabContentChildren)
}
return child
})
const TabsHeaderInChildren = React.Children.toArray(children).find((child) => (React.isValidElement(child) ? child.type === TabHeader : false)) as React.ReactElement | undefined
return (
<div
className={cn(styles.wrapper, styles.after, className)}
style={
{
'--active': 0,
'--count': TabsHeaderInChildren?.props?.children.length || 1
} as CSSProperties
}>
{TabsContentWithEnhancedChildren}
</div>
)
}
export { TabHeader, TabPanel, TabTrigger, Tabs, TabsContent }
'use client' // We split out this since we only need this to be client side.
const styles = {
input:
'sr-only [&:checked+label]:[--highlight:1] [&:checked+label]:bg-zinc-900 [&:not(:checked)+label:hover]:bg-[hsl(0,0%,20%)] [&:not(:checked)+label:hover]:[--highlight:0.35]',
label:
'grid cursor-pointer place-items-center transition-all whitespace-nowrap text-ellipsis duration-300 select-none ease-in-out h-9 rounded-md px-[clamp(0.5rem,2vw+0.25rem,2rem)] text-center text-[hsla(0,0%,100%,calc(.7+var(--highlight,0)))]'
}
export type TabTriggerProps = {
children?: React.ReactNode
index?: number
id?: string
}
const TabTrigger: React.FC<TabTriggerProps> = ({ children, index = 0, id }) => (
<>
<input
type="radio"
id={`${id}-${index}`}
name={id}
className={styles.input}
defaultChecked={index === 0}
onChange={(e) => {
const tabsContainer = e.currentTarget.closest('.tabs') as HTMLElement
if (tabsContainer) {
tabsContainer.style.setProperty('--active', index.toString())
// Find the next tab panel and the currently active tab panel
const nextTab = tabsContainer.querySelector(`.tab-content > :nth-child(${index + 1})`) as HTMLElement
const currentTab = tabsContainer.querySelector(`.tab-content > .active`) as HTMLElement
// Get the scrollHeight of the content within the current and next tabs
const currentTabHeight = currentTab?.querySelector('.height-div')?.scrollHeight
const nextTabHeight = nextTab?.querySelector('.height-div')?.scrollHeight
// Conditionally set min-height for a smooth transition (This prevents jumping content when switching tabs)
if (currentTabHeight && nextTabHeight) {
if (nextTabHeight > currentTabHeight) {
// If next tab's content is taller, set the min-height of the current tab to its own height
currentTab.style.setProperty('min-height', `${currentTabHeight}px`)
} else if (nextTabHeight < currentTabHeight) {
// If next tab's content is shorter, adjust both to ensure smooth transition
nextTab.style.setProperty('min-height', `${nextTabHeight}px`)
currentTab.style.setProperty('min-height', `${nextTabHeight}px`)
} else {
// If the content heights are the same, ensure both maintain this height
nextTab.style.setProperty('min-height', `${nextTabHeight}px`)
currentTab.style.setProperty('min-height', `${currentTabHeight}px`)
}
}
// Adjust the delay here as needed, previously mentioned to be 1 second, but code shows a delay of 1 millisecond
setTimeout(() => {
nextTab.classList.add('active')
currentTab.classList.remove('active')
// Remove 'active' class from all other tabs except the next tab
tabsContainer.querySelectorAll(`.tab-content > :not(:nth-child(${index + 1}))`).forEach((el) => {
el.classList.remove('active')
})
}, 1) // Ensure this delay is set according to your needs, may adjust to 1000 for 1 second if that was the intent
}
}}
/>
<label htmlFor={`${id}-${index}`} className={styles.label}>
{children}
</label>
</>
)
export default TabTrigger
import React from 'react'
export const isComponentMatching = (child: React.ReactNode, targetType: any): boolean => {
if (!React.isValidElement(child)) return false
if (React.isValidElement(child) && child.type === targetType) {
return true
}
let displayName = undefined
if (typeof window === 'undefined') {
// @ts-ignore
displayName = Array.isArray(child?.type?._payload?.value) ? child.type?._payload?.value?.at(-1) : child.type?._payload?.value?.displayName
} else {
// @ts-ignore
displayName = Array.isArray(child?.type?._payload?.value)
? // @ts-ignore
child.type?._payload?.value?.at(-1)
: // @ts-ignore
child?.type?._payload?.value?.name || child?.type?._payload?.value?.displayName
}
return [targetType.name, targetType.displayName].includes(displayName)
}
export function findComponentsOfType(children: React.ReactNode, componentType: React.ElementType): React.ReactElement<any, any>[] {
const foundComponents = React.Children.toArray(children).filter((child) => React.isValidElement(child) && isComponentMatching(child, componentType))
// Cast each found child as React.ReactElement since we've already verified they are valid elements.
return foundComponents as React.ReactElement<any, any>[]
}
export function findComponentOfType(children: React.ReactNode, componentType: React.ElementType): React.ReactElement | undefined {
const found = React.Children.toArray(children).find((child) => React.isValidElement(child) && isComponentMatching(child, componentType))
return found as React.ReactElement<any, any> | undefined
}
export function applyPropsToChildrenOfType(
children: React.ReactNode,
extraProps: any,
componentType: React.ElementType | React.ElementType[],
options: {
includeIndex?: boolean
recursive?: boolean
} = {}
): React.ReactNode {
const { includeIndex = false, recursive = false } = options
return React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) {
return child
}
const isEligibleComponent = Array.isArray(componentType) ? componentType.some((type) => isComponentMatching(child, type)) : isComponentMatching(child, componentType)
const props = isEligibleComponent ? { ...extraProps, ...(includeIndex ? { index } : {}) } : {}
if (!(child.props && child.props.children) || !recursive) {
return React.cloneElement(child, props)
}
const childProps = {
...child.props,
...props,
children: applyPropsToChildrenOfType(child.props.children, extraProps, componentType, options)
}
return React.cloneElement(child, childProps)
})
}
Usage
Props are not required for the Tabs component, but you can pass the following props to the Tabs component:
<Tabs animation={'slide-fade' | 'slide' | 'fade' | 'none'} animateHeight={false} animateHeight={false}>
<TabHeader>
<TabTrigger>Test</TabTrigger>
<TabTrigger>Example 2</TabTrigger>
<TabTrigger>Example 3</TabTrigger>
</TabHeader>
<TabsContent>
<TabPanel>Content for Test tab</TabPanel>
<TabPanel>
Content for Example 2 tab. Lorem Ipsum is simply dummy text of the printing and typesetting industry. Content for Example 2 tab. Lorem Ipsum is simply dummy text of the
printing and typesetting industry.
</TabPanel>
<TabPanel>Content for Example 3 tab. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s.</TabPanel>
</TabsContent>
</Tabs>Conclusion
This could have been a 100% RSC component, how ever I noticed some bugs of the height when switching between tabs. This could probably be fixed, but I decided to make a small part of the 'use client' and work on this later.