Anatomy of the GodUI Tabs
One indicator instead of N backgrounds, a FLIP that animates its box without animating width, and a first paint that looks exactly like shadcn.
↓Scroll to step through it
One indicator
In shadcn's tabs every trigger owns its active background: switching tabs turns one off and another on, so the highlight jumps.
GodUI adds one absolutely positioned <span> as the list's first child and
gives it the active styling: background, border and shadow for the default
variant, a 2px bar for line. Triggers come after it in the DOM and are
relative, so they paint on top of it. Once the indicator is in place, the
active trigger's own background turns transparent.
FLIP, not width
Tabs have different widths, so the indicator has to change size. Animating
width would run layout on every frame.
Instead it's a FLIP. First, remember the old box. Last, set the new
width, height and translate directly: one layout, no animation.
Invert, start an animation where scale shrinks or stretches the new box
back to the old one's size and translate puts it at the old position.
Play, animate both to identity. Only translate and scale change while
it moves, so it runs on the compositor.
The easing is read back from the indicator's own ease-spring-snappy class,
so the JavaScript animation uses the same linear() spring as the CSS
tokens. If you click again mid-slide, the running animation is cancelled and
the next one starts from the indicator's drawn box.
First paint without JS
The indicator needs a measurement, and there's nothing to measure on the
server. So it starts hidden, and the active trigger keeps shadcn's own
background.
After the first layout effect measures the active tab, the list gets
data-indicator="ready". That one attribute shows the indicator and turns the
trigger backgrounds transparent, in the same frame. Server HTML, the frame
before hydration and the frame after all look the same.
A MutationObserver on the triggers' data-state keeps the indicator in
sync, so controlled value changes move it too, not only clicks.
The result
Click between tabs, or focus the list and use the arrow keys. Click quickly to interrupt a slide: the next one starts from where the indicator is.
<TabsPrimitive.List ref={setListRef} data-slot="tabs-list" …><span ref={indicatorRef} data-slot="tabs-indicator" aria-hidden="true" className="pointer-events-none absolute top-0 left-0 hidden origin-top-left ease-spring-snappy group-data-[indicator=ready]/tabs-list:block group-data-[variant=default]/tabs-list:bg-background … "/>{children}</TabsPrimitive.List>Anatomy of the GodUI Tabs
One indicator instead of N backgrounds, a FLIP that animates its box without animating width, and a first paint that looks exactly like shadcn.
<TabsPrimitive.List ref={setListRef} data-slot="tabs-list" …><span ref={indicatorRef} data-slot="tabs-indicator" aria-hidden="true" className="pointer-events-none absolute top-0 left-0 hidden origin-top-left ease-spring-snappy group-data-[indicator=ready]/tabs-list:block group-data-[variant=default]/tabs-list:bg-background … "/>{children}</TabsPrimitive.List>One indicator
In shadcn's tabs every trigger owns its active background: switching tabs turns one off and another on, so the highlight jumps.
GodUI adds one absolutely positioned <span> as the list's first child and
gives it the active styling: background, border and shadow for the default
variant, a 2px bar for line. Triggers come after it in the DOM and are
relative, so they paint on top of it. Once the indicator is in place, the
active trigger's own background turns transparent.
indicator.style.width = `${box.w}px`;indicator.style.height = `${box.h}px`;indicator.style.translate = `${box.x}px ${box.y}px`;indicator.animate([ { translate: `${from.x}px ${from.y}px`, scale: `${from.w / box.w} ${from.h / box.h}` }, { translate: `${box.x}px ${box.y}px`, scale: "1 1" },],{ duration, easing },);FLIP, not width
Tabs have different widths, so the indicator has to change size. Animating
width would run layout on every frame.
Instead it's a FLIP. First, remember the old box. Last, set the new
width, height and translate directly: one layout, no animation.
Invert, start an animation where scale shrinks or stretches the new box
back to the old one's size and translate puts it at the old position.
Play, animate both to identity. Only translate and scale change while
it moves, so it runs on the compositor.
The easing is read back from the indicator's own ease-spring-snappy class,
so the JavaScript animation uses the same linear() spring as the CSS
tokens. If you click again mid-slide, the running animation is cancelled and
the next one starts from the indicator's drawn box.
// The trigger keeps shadcn's background until the list says the indicator is ready."data-[state=active]:bg-background …group-data-[indicator=ready]/tabs-list:data-[state=active]:bg-transparentgroup-data-[indicator=ready]/tabs-list:data-[state=active]:shadow-none"// After the first measure:list.setAttribute("data-indicator", "ready");First paint without JS
The indicator needs a measurement, and there's nothing to measure on the
server. So it starts hidden, and the active trigger keeps shadcn's own
background.
After the first layout effect measures the active tab, the list gets
data-indicator="ready". That one attribute shows the indicator and turns the
trigger backgrounds transparent, in the same frame. Server HTML, the frame
before hydration and the frame after all look the same.
A MutationObserver on the triggers' data-state keeps the indicator in
sync, so controlled value changes move it too, not only clicks.
The result
Click between tabs, or focus the list and use the arrow keys. Click quickly to interrupt a slide: the next one starts from where the indicator is.
What's animated
The indicator works for the line variant and for vertical tabs too.
| Interaction | Keyframe / mechanism | Properties | Easing | Duration |
|---|---|---|---|---|
| Switch tab | FLIP (WAAPI) on the indicator | translate, scale | ease-spring-snappy | 260ms |
| Panel enter | godui-fade-in | opacity | ease-spring-snappy | 260ms |
| Trigger text color | snaps | none | none | none |
The indicator moves whenever the active tab changes: clicks, keyboard arrows,
or a controlled value set from outside. Before JavaScript runs (server
render, first paint) the active trigger keeps shadcn's own background, so
nothing flashes.
If a call site styles the active trigger itself (for example
data-[state=active]:bg-primary), that styling still wins: it paints over the
indicator, so the tab looks exactly as before and switches without the slide.
Why GPU-only
The indicator animates translate and scale; panels animate opacity.
shadcn's triggers use transition-all, which animates background color,
shadow and the focus ring. GodUI's triggers don't transition at all.
Reduced motion
The indicator checks prefers-reduced-motion before animating and jumps
straight to the new tab. Panels fade for 150ms.
Replacing shadcn
npx shadcn add @godui/tabs overwrites components/ui/tabs.tsx. Exports,
props and data-slot attributes match shadcn/ui new-york-v4, so existing
imports keep working. TabsList renders one extra element, an aria-hidden
<span data-slot="tabs-indicator">. It also installs godui-motion (easings,
keyframes, the FLIP hook) into your project.