Elastic Tabs
loomui/elastic-tabsFreeComponent
A tab group whose pill stretches to cover both tabs before it contracts onto the one you picked.
Live preview
live · rendered by the registry
Rendered by loomui on their own page — this is the component running, not a screenshot of it.Install it
npx shadcn@latest add https://loomui.design/r/elastic-tabs.jsonSource
1"use client"23import * as React from "react"45import { cn } from "@/lib/utils"67export interface ElasticTabsItem {8 value: string9 label: React.ReactNode10}1112export interface ElasticTabsProps extends Omit<13 React.ComponentProps<"div">,14 "onChange"15> {16 /** The tabs, in order. */17 items: ElasticTabsItem[]18 /** Selected tab. Leave it out to let the group own the selection. */19 value?: string20 /** Starting tab when the group owns the selection. */21 defaultValue?: string22 /** Called with the value of the tab moved to. */23 onValueChange?: (value: string) => void24 /** Length of the whole travel, in milliseconds. */25 duration?: number26}2728const EMPTY = { left: 0, width: 0 }2930export function ElasticTabs({31 items,32 value,33 defaultValue,34 onValueChange,35 duration = 280,36 className,37 ...props38}: ElasticTabsProps) {39 const [own, setOwn] = React.useState(defaultValue ?? items[0]?.value)40 const isControlled = value !== undefined41 const active = isControlled ? value : own42 const index = Math.max(43 items.findIndex((item) => item.value === active),44 045 )4647 const listRef = React.useRef<HTMLDivElement>(null)48 const tabsRef = React.useRef<(HTMLButtonElement | null)[]>([])49 const [pill, setPill] = React.useState(EMPTY)50 // The first placement is a measurement, not a move, so it must not travel.51 const [settled, setSettled] = React.useState(false)5253 const measure = React.useCallback((position: number) => {54 const tab = tabsRef.current[position]55 return tab ? { left: tab.offsetLeft, width: tab.offsetWidth } : EMPTY56 }, [])5758 const indexRef = React.useRef(index)59 indexRef.current = index60 const pillRef = React.useRef(EMPTY)61 React.useEffect(() => {62 pillRef.current = pill63 }, [pill])6465 // Placement. Runs once on mount and again whenever the row is resized, so a66 // wrapped or re-laid-out group never leaves the pill behind.67 React.useEffect(() => {68 const list = listRef.current69 if (!list) {70 return71 }7273 const observer = new ResizeObserver(() =>74 setPill(measure(indexRef.current))75 )76 observer.observe(list)7778 const timer = window.setTimeout(() => setSettled(true), 0)79 return () => {80 observer.disconnect()81 window.clearTimeout(timer)82 }83 }, [measure])8485 // Travel. The pill first stretches to cover both tabs, then contracts onto86 // the new one. Moving `left` and `width` straight to the target slides a87 // fixed shape across. Spanning first is what makes it read as elastic.88// … truncated
What it pulls in
Other registry items
Files it writes
More from loomui
All 91 items| Component | Registry | Kind | Access | Installs | Command |
|---|---|---|---|---|---|
| Aurora Backdropaurora-backdrop | loomui | Components | Free | no deps | |
| Bento Gridbento-grid | loomui | Components | Free | no deps | |
| Card Stackcard-stack | loomui | Components | Free | no deps | |
| Compare Slidercompare-slider | loomui | Components | Free | no deps | |
| Confetti Buttonconfetti-button | loomui | Components | Free | no deps | |
| Count Upcount-up | loomui | Components | Free | no deps | |
| Credit Cardcredit-card | loomui | Components | Free | no deps | |
| Drawerdrawer | loomui | Components | Free | 1 dep |
