🚧 Under development — suggestions for new items are always welcome! ✨

Tabs

Tabs let you stack several content sections in the same spot, with only one section visible at any given moment.

Loading MDX…

Install

pnpm dlx [email protected] add https://p.livog.com/r/tabs.json

Code

'use client'

import React, {
  createContext,
  use,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ComponentPropsWithoutRef,
  type ReactNode,
  type RefObject
} from 'react'
import clsx from 'clsx'

type TabPosition = 'top' | 'right' | 'bottom' | 'left'
type TabAnimation = 'directional' | 'slide' | 'fade' | 'none'

type TabsContextValue = {
  activeValue: string
  previewValue: string | null
  changeTab: (next: string) => void
  setPreviewValue: (v: string | null) => void
  registerTab: (value: string) => number
  registerPanel: (value: string) => number
  nextAutoTabValue: () => string
  nextAutoPanelValue: () => string
  orderRef: RefObject<string[]>
  position: TabPosition
  animation: TabAnimation
  animateHeight: boolean
  equalHeight: boolean
  unmount: boolean
  rootRef: RefObject<HTMLDivElement | null>
  contentRef: RefObject<HTMLDivElement | null>
  valueProp?: string
  defaultValueProp?: string
}

const TabsContext = createContext<TabsContextValue | null>(null)
const useTabs = () => use(TabsContext) as TabsContextValue

const freezeContainerHeight = (rootRef: RefObject<HTMLDivElement | null>) => {
  const c = rootRef.current?.querySelector<HTMLDivElement>('.tab-content')
  if (!c) return

  heightObservers.get(c)?.disconnect()
  heightObservers.delete(c)
  targetCache.delete(c)

  c.style.height = `${c.offsetHeight}px`
}

export function Tabs({
  children,
  defaultValue,
  value: valueProp,
  onValueChange,
  position = 'top',
  animation = 'none',
  animateHeight = false,
  equalHeight = false,
  unmount = true,
  ...rest
}: {
  children: ReactNode
  defaultValue?: string
  value?: string
  onValueChange?: (v: string) => void
  position?: TabPosition
  animation?: TabAnimation
  animateHeight?: boolean
  equalHeight?: boolean
  unmount?: boolean
} & React.HTMLAttributes<HTMLDivElement>) {
  const orderRef = useRef<string[]>([])
  const panelOrderRef = useRef<string[]>([])

  const registerTab = useCallback((v: string) => {
    const i = orderRef.current.indexOf(v)
    if (i !== -1) return i
    orderRef.current.push(v)
    return orderRef.current.length - 1
  }, [])

  const registerPanel = useCallback((v: string) => {
    const i = panelOrderRef.current.indexOf(v)
    if (i !== -1) return i
    panelOrderRef.current.push(v)
    return panelOrderRef.current.length - 1
  }, [])

  const autoTabCounter = useRef(0)
  const autoPanelCounter = useRef(0)
  autoTabCounter.current = 0
  autoPanelCounter.current = 0
  const nextAutoTabValue = () => String(autoTabCounter.current++)
  const nextAutoPanelValue = () => String(autoPanelCounter.current++)

  const controlled = valueProp !== undefined

  const [controlledValue, setControlledValue] = useState(() => valueProp ?? '')
  const [uncontrolledValue, setUncontrolledValue] = useState(() => defaultValue ?? '')
  const [previewValue, setPreviewValue] = useState<string | null>(null)

  useEffect(() => {
    if (!controlled || valueProp === undefined || valueProp === controlledValue) return
    if (!animateHeight) {
      setControlledValue(valueProp)
      return
    }
    freezeContainerHeight(rootRef)
    setPreviewValue(valueProp)
    requestAnimationFrame(() => {
      setControlledValue(valueProp)
      setPreviewValue(null)
    })
  }, [valueProp, controlled, animateHeight, controlledValue])

  const updateActive = useCallback(
    (v: string) => {
      if (controlled) onValueChange?.(v)
      else {
        setUncontrolledValue(v)
        onValueChange?.(v)
      }
    },
    [controlled, onValueChange]
  )

  const currentActive = controlled ? controlledValue : uncontrolledValue

  const changeTab = useCallback(
    (next: string) => {
      if (next === currentActive) return
      const doPreview = animateHeight || unmount
      if (!doPreview) {
        updateActive(next)
        return
      }
      if (animateHeight) freezeContainerHeight(rootRef)
      setPreviewValue(next)
      requestAnimationFrame(() => {
        updateActive(next)
        setPreviewValue(null)
      })
    },
    [currentActive, animateHeight, unmount, updateActive]
  )

  const rootRef = useRef<HTMLDivElement | null>(null)
  const contentRef = useRef<HTMLDivElement | null>(null)

  return (
    <TabsContext.Provider
      value={{
        activeValue: currentActive,
        previewValue,
        changeTab,
        setPreviewValue,
        registerTab,
        registerPanel,
        nextAutoTabValue,
        nextAutoPanelValue,
        orderRef,
        position,
        animation,
        animateHeight,
        equalHeight,
        unmount,
        rootRef,
        contentRef,
        valueProp,
        defaultValueProp: defaultValue
      }}>
      <div
        {...rest}
        ref={rootRef}
        data-position={position}
        data-animation={animation}
        data-equal-height={equalHeight || undefined}
        className={clsx('tabs', rest.className)}>
        {children}
      </div>
    </TabsContext.Provider>
  )
}

type TabsListProps = React.ComponentPropsWithoutRef<'div'>

export function TabsList({ className, ...props }: TabsListProps) {
  return <div role="tablist" className={clsx('tab-list', className)} {...props} />
}

export function TabsTrigger({
  value: valueProp,
  children,
  className,
  ...rest
}: { value?: string; children: ReactNode } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
  const {
    activeValue,
    changeTab,
    orderRef,
    position,
    registerTab,
    valueProp: ctxValueProp,
    defaultValueProp: ctxDefaultValue,
    previewValue,
    nextAutoTabValue
  } = useTabs()

  const memoRef = useRef<{ value: string; index: number } | null>(null)
  if (!memoRef.current) {
    const provisional = valueProp ?? nextAutoTabValue()
    const idx = registerTab(provisional)
    memoRef.current = { value: provisional, index: idx }
  }
  const { value, index } = memoRef.current

  const isActive = useMemo(() => {
    if (previewValue) return previewValue === value
    if (activeValue) return activeValue === value
    if (ctxValueProp !== undefined) return ctxValueProp === value
    if (ctxDefaultValue !== undefined) return ctxDefaultValue === value
    return index === 0
  }, [previewValue, ctxValueProp, activeValue, ctxDefaultValue, index, value])

  const onKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
    const order = orderRef.current
    const i = activeValue ? order.indexOf(activeValue) : 0
    const wrap = (n: number) => (n + order.length) % order.length
    const horiz = position === 'top' || position === 'bottom'
    const next: Record<string, () => void> = {
      ArrowLeft: () => horiz && changeTab(order[wrap(i - 1)]),
      ArrowRight: () => horiz && changeTab(order[wrap(i + 1)]),
      ArrowUp: () => !horiz && changeTab(order[wrap(i - 1)]),
      ArrowDown: () => !horiz && changeTab(order[wrap(i + 1)]),
      Home: () => changeTab(order[0]),
      End: () => changeTab(order[order.length - 1])
    }
    const fn = next[e.key]
    if (!fn) return
    e.preventDefault()
    fn()
  }

  return (
    <button
      role="tab"
      type="button"
      aria-selected={isActive}
      data-active={isActive}
      data-value={value}
      className={clsx('tab-trigger', className)}
      onClick={() => changeTab(value)}
      onKeyDown={onKeyDown}
      {...rest}>
      {children}
    </button>
  )
}

export function TabsContent({ children, className, ...rest }: ComponentPropsWithoutRef<'div'>) {
  const { activeValue, animateHeight, rootRef, contentRef } = useTabs()

  useEffect(() => {
    if (!animateHeight || !rootRef.current) return
    requestAnimationFrame(() => lockHeight(rootRef, activeValue))
  }, [activeValue, animateHeight, rootRef])

  return (
    <div ref={contentRef} className={clsx('tab-content', className)} {...rest}>
      {children}
    </div>
  )
}

type TabPanelProps = ComponentPropsWithoutRef<'div'> & { children: ReactNode; value?: string }

export function TabPanel({ children, className, value: valueProp, ...rest }: TabPanelProps) {
  const {
    unmount,
    orderRef,
    previewValue,
    activeValue,
    valueProp: ctxValueProp,
    defaultValueProp: ctxDefaultValue,
    registerPanel,
    nextAutoPanelValue,
    equalHeight,
    contentRef,
  } = useTabs()

  /* stable mapping ----------------------------------------------------- */
  const memoRef = useRef<{ value: string; index: number } | null>(null)
  if (!memoRef.current) {
    const slot = nextAutoPanelValue()
    const provisional = valueProp ?? orderRef.current[parseInt(slot, 10)] ?? slot
    const idx = registerPanel(provisional)
    memoRef.current = { value: provisional, index: idx }
  }
  const { value: derivedValue } = memoRef.current

  /* derived flags ------------------------------------------------------ */
  const isActive = useMemo(() => {
    if (previewValue) return previewValue === derivedValue
    if (activeValue) return activeValue === derivedValue
    if (ctxValueProp !== undefined) return ctxValueProp === derivedValue
    if (ctxDefaultValue !== undefined) return ctxDefaultValue === derivedValue
    return orderRef.current.indexOf(derivedValue) === 0
  }, [previewValue, ctxValueProp, activeValue, ctxDefaultValue, derivedValue, orderRef])

  const isPreview = previewValue === derivedValue

  const activeIdx = useMemo(() => {
    const val =
      previewValue ??
      (ctxValueProp !== undefined ? ctxValueProp : activeValue ?? ctxDefaultValue ?? '')
    const i = orderRef.current.indexOf(val)
    return i !== -1 ? i : /^\d+$/.test(val) ? parseInt(val, 10) : 0
  }, [previewValue, ctxValueProp, activeValue, ctxDefaultValue, orderRef])

  const myIdx = orderRef.current.indexOf(derivedValue)
  const state = myIdx === activeIdx ? 'active' : myIdx < activeIdx ? 'before' : 'after'

  /* lazy‑mount / unmount ---------------------------------------------- */
  const ref = useRef<HTMLDivElement>(null)
  const [showChildren, setShowChildren] = useState(isActive || isPreview)

  useEffect(() => {
    if (!unmount) { setShowChildren(true); return }
  
    if (isActive || isPreview) {           // entering panel stays mounted
      setShowChildren(true)
      return
    }
  
    const node = ref.current
    if (!node) return
  
    const cs      = getComputedStyle(node)
    const durs    = cs.transitionDuration.split(',').map(s => s.trim())
    const delays  = cs.transitionDelay.split(',').map(s => s.trim())
    const toMs    = (v: string) => (v.endsWith('ms') ? parseFloat(v) : parseFloat(v||'0')*1000)
    const longest = durs.reduce((m, d, i) => Math.max(m, toMs(d)+toMs(delays[i] ?? '0')), 0)
  
    const tidy = () => setShowChildren(false)

    if (longest === 0) {
      const raf = requestAnimationFrame(() => tidy())
      return () => cancelAnimationFrame(raf)
    }
  
    node.addEventListener('transitionend', tidy, { once: true })
    const timer = window.setTimeout(tidy, (longest || 100) + 50)
  
    return () => {
      node.removeEventListener('transitionend', tidy)
      clearTimeout(timer)
    }
  }, [isActive, isPreview, unmount])

  const shouldShow = showChildren || isActive || isPreview || equalHeight

  /* render ------------------------------------------------------------- */
  return (
    <div
      ref={ref}
      role="tabpanel"
      className={clsx('tab-panel', className)}
      data-state={state}
      data-value={derivedValue}
      data-active={isActive}
      onTransitionEnd={() => {
        if (isActive && contentRef.current) {
          contentRef.current.style.removeProperty('height')
        }
      }}
      {...rest}
    >
      {shouldShow ? children : null}
    </div>
  )
}


const heightObservers = new WeakMap<HTMLElement, ResizeObserver>()
const distanceCache = new WeakMap<HTMLElement, number>()
const targetCache = new WeakMap<HTMLElement, number>()

export function lockHeight(rootRef: RefObject<HTMLDivElement | null>, nextVal: string) {
  if (!rootRef.current) return
  const c = rootRef.current.querySelector<HTMLDivElement>('.tab-content')
  const p = rootRef.current.querySelector<HTMLDivElement>(`.tab-panel[data-value="${CSS.escape(nextVal)}"]`)
  if (!c || !p) return

  const to = p.scrollHeight
  if (targetCache.get(c) === to) return
  const from = c.offsetHeight
  if (from === to) return

  heightObservers.get(c)?.disconnect()

  const prev = c.style.transition
  c.style.height = `${from}px`

  const durStr = getComputedStyle(c).transitionDuration.split(',')[0].trim()
  const base = durStr.endsWith('ms') ? parseFloat(durStr) : parseFloat(durStr) * 1000
  const last = distanceCache.get(c) ?? Math.abs(from - to)
  const dur = Math.max(base * Math.min(Math.abs(from - to) / last, 1), 150)

  distanceCache.set(c, Math.abs(from - to))

  c.style.transition = prev || ''
  c.style.transitionDuration = `${dur}ms`
  c.style.height = `${to}px`
  targetCache.set(c, to)

  const tidy = () => {
    heightObservers.get(c)?.disconnect()
    targetCache.delete(c)
    c.style.height = ''
    c.style.removeProperty('transition-duration')
  }

  const ro = new ResizeObserver((e) => {
    for (const t of e) if (Math.abs((t.target as HTMLElement).offsetHeight - to) < 1) tidy()
  })
  ro.observe(c)
  heightObservers.set(c, ro)
}

Basic Usage

Update your profile information.

Animation Usage

This panel is short to emphasise the height change when switching to the longer panel.

Enjoy the smooth slide-in animation.