Taffy Tabs
A playful tab bar whose indicator stretches toward the next tab and snaps into place.
npx shadcn@latest add https://sundaeui.dev/r/taffy-tabs.json'use client'
import { useLayoutEffect, useRef, useState } from 'react'
import './taffy-tabs.css'
export interface TaffyTab {
id: string
label: string
icon?: React.ReactNode
/** Indicator colour while this tab is active. */
color?: string
}
export interface TaffyTabsProps {
tabs: TaffyTab[]
value?: string
defaultValue?: string
onValueChange?: (id: string) => void
/** Accessible name for the tab list. */
label?: string
/** Pull items toward the pointer. */
magnetic?: boolean
className?: string
}
/**
* A tab bar whose indicator stretches toward the new tab — the leading edge
* moves first and the trailing edge catches up — then settles with a spring.
*/
export function TaffyTabs({
tabs,
value,
defaultValue,
onValueChange,
label = 'Sections',
magnetic = true,
className,
}: TaffyTabsProps) {
const [internal, setInternal] = useState(defaultValue ?? tabs[0]?.id)
const active = value ?? internal
const listRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef(new Map<string, HTMLButtonElement>())
const prevIndex = useRef(tabs.findIndex((t) => t.id === active))
const [edges, setEdges] = useState<{ l: number; r: number; dir: 1 | -1 } | null>(null)
const index = tabs.findIndex((t) => t.id === active)
useLayoutEffect(() => {
const measure = () => {
const el = itemRefs.current.get(active)
const list = listRef.current
if (!el || !list) return
const l = el.offsetLeft
const r = list.clientWidth - (el.offsetLeft + el.offsetWidth)
setEdges({ l, r, dir: index >= prevIndex.current ? 1 : -1 })
prevIndex.current = index
}
measure()
const ro = new ResizeObserver(measure)
if (listRef.current) ro.observe(listRef.current)
return () => ro.disconnect()
}, [active, index])
const select = (id: string) => {
if (value === undefined) setInternal(id)
onValueChange?.(id)
}
const onKeyDown = (e: React.KeyboardEvent) => {
const keys: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }
let next = index
if (e.key in keys) next = (index + keys[e.key] + tabs.length) % tabs.length
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = tabs.length - 1
else return
e.preventDefault()
select(tabs[next].id)
itemRefs.current.get(tabs[next].id)?.focus()
}
const color = tabs[index]?.color
return (
<div
ref={listRef}
role="tablist"
aria-label={label}
className={['sd-taffy', className].filter(Boolean).join(' ')}
onKeyDown={onKeyDown}
style={
{
'--taffy-l': edges ? `${edges.l}px` : undefined,
'--taffy-r': edges ? `${edges.r}px` : undefined,
'--taffy-color': color,
} as React.CSSProperties
}
data-dir={edges?.dir === -1 ? 'left' : 'right'}
data-ready={edges ? '' : undefined}
>
<span className="sd-taffy__blob" aria-hidden="true" />
{tabs.map((t) => (
<button
key={t.id}
ref={(el) => {
if (el) itemRefs.current.set(t.id, el)
else itemRefs.current.delete(t.id)
}}
type="button"
role="tab"
aria-selected={t.id === active}
tabIndex={t.id === active ? 0 : -1}
className="sd-taffy__tab"
onClick={() => select(t.id)}
onPointerMove={
magnetic
? (e) => {
const r = e.currentTarget.getBoundingClientRect()
const x = (e.clientX - (r.left + r.width / 2)) / r.width
const y = (e.clientY - (r.top + r.height / 2)) / r.height
e.currentTarget.style.setProperty('--mx', `${x * 6}px`)
e.currentTarget.style.setProperty('--my', `${y * 4}px`)
}
: undefined
}
onPointerLeave={(e) => {
e.currentTarget.style.removeProperty('--mx')
e.currentTarget.style.removeProperty('--my')
}}
>
{t.icon && <span className="sd-taffy__icon">{t.icon}</span>}
<span className="sd-taffy__label">{t.label}</span>
</button>
))}
</div>
)
}/* TaffyTabs — Sundae S—002 */
.sd-taffy {
--taffy-bg: #fff4e8;
--taffy-fg: #3b2a1e;
--taffy-fg-active: #fffaf4;
--taffy-color: #ff6b3d;
--taffy-height: 48px;
--taffy-pad: 5px;
--taffy-slow: 520ms;
--taffy-fast: 260ms;
position: relative;
display: inline-flex;
gap: 2px;
padding: var(--taffy-pad);
border-radius: 999px;
background: var(--taffy-bg);
box-shadow:
inset 0 0 0 1px rgb(59 42 30 / 0.08),
0 10px 30px -12px rgb(160 80 30 / 0.35);
font-family: ui-rounded, 'SF Pro Rounded', ui-sans-serif, system-ui, sans-serif;
isolation: isolate;
}
:where(.dark) .sd-taffy {
--taffy-bg: #231a14;
--taffy-fg: #e9d9c8;
--taffy-fg-active: #1a120c;
box-shadow:
inset 0 0 0 1px rgb(255 230 200 / 0.08),
0 10px 30px -12px rgb(0 0 0 / 0.6);
}
.sd-taffy__blob {
position: absolute;
z-index: -1;
top: var(--taffy-pad);
bottom: var(--taffy-pad);
left: calc(var(--taffy-l, 0px));
right: calc(var(--taffy-r, 100%));
border-radius: 999px;
background: var(--taffy-color);
box-shadow:
0 6px 16px -6px color-mix(in oklab, var(--taffy-color) 70%, transparent),
inset 0 1px 0 rgb(255 255 255 / 0.35);
opacity: 0;
transition:
background-color 400ms ease,
box-shadow 400ms ease,
opacity 200ms ease;
}
.sd-taffy[data-ready] .sd-taffy__blob {
opacity: 1;
}
/* Leading edge fast, trailing edge slow, both with a little overshoot. */
.sd-taffy[data-ready][data-dir='right'] .sd-taffy__blob {
transition:
right var(--taffy-fast) cubic-bezier(0.3, 1.4, 0.6, 1),
left var(--taffy-slow) cubic-bezier(0.5, 1.35, 0.45, 1),
background-color 400ms ease,
box-shadow 400ms ease,
opacity 200ms ease;
}
.sd-taffy[data-ready][data-dir='left'] .sd-taffy__blob {
transition:
left var(--taffy-fast) cubic-bezier(0.3, 1.4, 0.6, 1),
right var(--taffy-slow) cubic-bezier(0.5, 1.35, 0.45, 1),
background-color 400ms ease,
box-shadow 400ms ease,
opacity 200ms ease;
}
.sd-taffy__tab {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
height: calc(var(--taffy-height) - var(--taffy-pad) * 2);
padding: 0 16px;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--taffy-fg);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
cursor: pointer;
transform: translate(var(--mx, 0), var(--my, 0));
transition:
color 260ms ease,
transform 360ms cubic-bezier(0.3, 1.5, 0.5, 1);
-webkit-tap-highlight-color: transparent;
}
.sd-taffy__tab:hover:not([aria-selected='true']) {
color: color-mix(in oklab, var(--taffy-fg) 75%, var(--taffy-color));
}
.sd-taffy__tab[aria-selected='true'] {
color: var(--taffy-fg-active);
}
.sd-taffy__tab:active {
transform: translate(var(--mx, 0), var(--my, 0)) scale(0.94);
}
.sd-taffy__tab:focus-visible {
outline: 2px solid var(--taffy-color);
outline-offset: 2px;
}
.sd-taffy__icon {
display: grid;
width: 18px;
height: 18px;
place-items: center;
transition: transform 420ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
.sd-taffy__tab[aria-selected='true'] .sd-taffy__icon {
transform: rotate(-8deg) scale(1.08);
}
.sd-taffy__icon svg {
width: 100%;
height: 100%;
}
@media (max-width: 520px) {
.sd-taffy__tab {
padding: 0 12px;
}
.sd-taffy__tab:not([aria-selected='true']) .sd-taffy__label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
}
@media (prefers-reduced-motion: reduce) {
.sd-taffy__blob,
.sd-taffy__tab,
.sd-taffy__icon {
transition-duration: 1ms !important;
}
}Overview
The indicator is one element whose left and right edges are animated separately. When you switch tabs, the leading edge moves first and the trailing edge follows a beat later, both with a little overshoot. The pill stretches like taffy and settles.
Each tab can bring its own colour, and the indicator blends between them as it travels. Items lean slightly toward the pointer, and press states squash a little, so the bar feels physical without becoming noisy.
Use it for bottom navigation in consumer apps, view switchers and onboarding steps. Anywhere a bit of character is welcome.
When to use
- Moving between a handful of destinations or sections.
When not to use
- Large or deep hierarchies: use a sidebar or search.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/taffy-tabs.jsonnpx shadcn@latest add https://sundaeui.dev/r/taffy-tabs.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/taffy-tabs.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/taffy-tabs.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy taffy-tabs.tsx and taffy-tabs.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { TaffyTabs } from '@/components/sundae/taffy-tabs'
<TaffyTabs
label="App sections"
defaultValue="home"
onValueChange={(id) => router.push('/' + id)}
tabs={[
{ id: 'home', label: 'Home', color: '#ff6b3d', icon: <HomeIcon /> },
{ id: 'explore', label: 'Explore', color: '#2f9e6e', icon: <CompassIcon /> },
{ id: 'saved', label: 'Saved', color: '#e0a100', icon: <BookmarkIcon /> },
]}
/>Props
| Prop | Type | Default |
|---|---|---|
tabs*Items: { id, label, icon?, color? }. | TaffyTab[] | — |
valueControlled active tab id. | string | — |
defaultValueInitial tab for uncontrolled use. Defaults to the first tab. | string | — |
onValueChangeCalled when the user picks a tab. | (id: string) => void | — |
labelAccessible name of the tab list. | string | 'Sections' |
magneticItems lean slightly toward the pointer. | boolean | true |
Accessibility
- Implements the WAI-ARIA tabs pattern: role="tablist" and role="tab", aria-selected, and roving tabindex.
- Arrow keys, Home and End move between tabs, and focus follows the selection.
- Below 520px, inactive labels are visually hidden but stay readable by screen readers.
Customization
- Change the timing with --taffy-fast and --taffy-slow. A bigger gap between them gives more stretch.
- Restyle the surface and text with --taffy-bg, --taffy-fg and --taffy-fg-active.
- Pair it with role="tabpanel" elements labelled by each tab to complete the pattern.
FAQ
- Is Taffy Tabs free?
- Yes. Taffy Tabs is free for personal and commercial projects, with full source on this page.
- How do I install Taffy Tabs?
- Run npx shadcn@latest add https://sundaeui.dev/r/taffy-tabs.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Taffy Tabs support keyboard and screen readers?
- Implements the WAI-ARIA tabs pattern: role="tablist" and role="tab", aria-selected, and roving tabindex. Arrow keys, Home and End move between tabs, and focus follows the selection.
- Does Taffy Tabs respect reduced motion?
- Yes. With prefers-reduced-motion set, decorative motion is removed and state changes are instant.
More like this
All navigation →Orbit Nav
Navigation · Playful- Pro
Magnify Dock
Navigation · Playful Underline Tabs
Navigation · RefinedVertical Tabs
Navigation · RefinedBottom Tab Bar
Navigation · Refined- Pro
Command Palette
Navigation · Refined