Skip to contentVibraUI
Foundation

useSlidingIndicator

Measures the selected item in a list and positions one indicator on it, so a pill or an underline slides instead of blinking.

One measurement, shared by Tabs, SegmentedControl and NavTabs, because three copies of it drift apart — one animates width, one animates left, and the third forgets to re-measure when the display font swaps in. It reads the active element's offset box rather than a rect against the viewport, so a scrolled or transformed ancestor cannot skew it, and a ResizeObserver on the list catches the font swap, a window resize and a tab being added. Before the first measurement it reports ready: false and a style of opacity: 0, so a tab row never starts with the pill at x=0 sliding into place.

Install

npx shadcn@latest add @vibra/use-sliding-indicator

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
valuestring | number | null—The selected value; a change re-measures.
orientation"horizontal" | "vertical""horizontal"Which axis the indicator travels on.
returns.listRefRefObject<HTMLElement | null>—Put it on the element the indicator is positioned inside.
returns.itemRef(active: boolean) => (node: HTMLElement | null) => void—Ref callback for each item; only the active one is recorded.
returns.styleReact.CSSProperties—Transform and size for the indicator, or opacity 0 before the first measurement.
returns.readyboolean—False until the first measurement lands.

Dependencies

Registry

Source

hooks/use-sliding-indicator.ts
"use client"

import * as React from "react"

/**
 * The pill or the underline that slides from one tab to the next.
 *
 * One measurement, shared by Tabs, SegmentedControl and NavTabs, because three
 * copies of "measure the active child and translate a box onto it" drift apart:
 * one animates width, one animates left, and the third forgets to re-measure
 * when the font loads.
 *
 * It reads the active element's offset box rather than a `getBoundingClientRect`
 * against the list, so a scrolled or transformed ancestor cannot skew it, and it
 * re-measures on a resize and whenever the active value changes. Before the
 * first measurement the indicator is `ready: false` — mount it hidden, so a tab
 * row does not start with a pill at x=0 sliding into place.
 */
export type SlidingIndicator = {
  /** Put this on the list element the indicator is positioned inside. */
  listRef: React.RefObject<HTMLElement | null>
  /** Marks the element the indicator should sit on. Falsy `active` is ignored. */
  itemRef: (active: boolean) => (node: HTMLElement | null) => void
  /** False until the first measurement lands. */
  ready: boolean
  /** Inline style for the indicator: a horizontal one translates and sizes. */
  style: React.CSSProperties
}

export type UseSlidingIndicatorOptions = {
  /** Re-measures whenever this changes — pass the selected value. */
  value?: string | number | null
  orientation?: "horizontal" | "vertical"
}

export function useSlidingIndicator({
  value,
  orientation = "horizontal",
}: UseSlidingIndicatorOptions = {}): SlidingIndicator {
  const listRef = React.useRef<HTMLElement | null>(null)
  const activeRef = React.useRef<HTMLElement | null>(null)
  const [box, setBox] = React.useState<{ start: number; size: number } | null>(null)

  const measure = React.useCallback(() => {
    const node = activeRef.current
    if (!node || !listRef.current) return
    setBox(
      orientation === "horizontal"
        ? { start: node.offsetLeft, size: node.offsetWidth }
        : { start: node.offsetTop, size: node.offsetHeight }
    )
  }, [orientation])

  // The ref callback records the active node; the layout effect below is what
  // measures it, so the read happens after React has committed the DOM.
  const itemRef = React.useCallback(
    (active: boolean) => (node: HTMLElement | null) => {
      if (active && node) activeRef.current = node
    },
    []
  )

  React.useLayoutEffect(() => {
    measure()
  }, [measure, value])

  React.useEffect(() => {
    const list = listRef.current
    if (!list || typeof ResizeObserver === "undefined") return
    // The list resizes when the window does, when a label's font swaps in, and
    // when a tab is added; one observer covers all three.
    const observer = new ResizeObserver(() => measure())
    observer.observe(list)
    if (activeRef.current) observer.observe(activeRef.current)
    return () => observer.disconnect()
  }, [measure, value])

  return {
    listRef,
    itemRef,
    ready: box !== null,
    style: box
      ? orientation === "horizontal"
        ? { transform: `translateX(${box.start}px)`, width: box.size }
        : { transform: `translateY(${box.start}px)`, height: box.size }
      : { opacity: 0 },
  }
}