Tabs

90% RSC~750B gzipped~1.57kb parsed size

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.