Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .specs/tab-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ spec_version: 2
figma:
url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=3374-6191
node_id: 3374:6191
checksum: 9a23a13b0205624a780b6ac76086455a35d38ac2b266757cbf91080258308e6b
checksum: 38b0be72fee4fa515e9917d1f931e6b0d567009d1e3fb46dd32d4e3ad86c6538
created: 2026-05-22
last_updated: 2026-07-22
last_updated: 2026-07-28
---
# Tab View — Component Spec

Expand Down Expand Up @@ -53,6 +53,7 @@ Helps users move between views or sections. Migrated from the existing implement
| open/close | `animate-none` | semantic | `motion-reduce:animate-none` |
| scroll edge fade appears / leaves | `animate-fade-in` / `animate-fade-out` | semantic | `motion-reduce:animate-none` |
| state change | `transition-colors duration-150 ease-out` | inline | `motion-reduce:transition-none` |
| panel change | the incoming panel slides in from the side being navigated toward (`translate-x-[var(--spacing-md)]` / negative) while fading | `duration['moderate-02']` + `curve['productive-entrance']` from the theme animation catalog | `motion-reduce:transition-none` |

## Tokens

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,21 @@ export const tabViewIndicatorMotion = {
slide: { duration: duration['moderate-02'], curve: curve['productive-entrance'] }
} as const

/*
* `translate` is listed alongside `transform` on purpose. Tailwind v4 compiles
* `translate-x-*` to the standalone `translate` property (`translate: var(--tw-translate-x)
* var(--tw-translate-y)`), and its `.transform` utility no longer covers translation —
* it only composes rotate/skew. A list of just `transform, opacity` therefore leaves the
* slide offset un-interpolated: it snaps to its final position while only the fade plays,
* so a panel/month swap reads as a pop instead of a slide. Tailwind's own
* `transition-transform` expands to `transform, translate, scale, rotate` for the same
* reason. Any offset expressed with a `translate-*` utility must keep `translate` here.
*/
export const getTabViewPanelTransitionStyle = (): { transition: string } => {
const { duration: transitionDuration, curve: transitionTimingFunction } = tabViewPanelMotion.enter

return {
transition: `transform ${transitionDuration} ${transitionTimingFunction}, opacity ${transitionDuration} ${transitionTimingFunction}`
transition: `translate ${transitionDuration} ${transitionTimingFunction}, transform ${transitionDuration} ${transitionTimingFunction}, opacity ${transitionDuration} ${transitionTimingFunction}`
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { defineComponent, ref } from 'vue'
import * as stories from '../../../../../../apps/storybook/src/stories/components/navigation/tab-view/TabView.stories'
import { expectNoA11yViolations } from '../../../test/axe'
import TabView from './index'
import { getTabViewPanelTransitionStyle } from './presets/transitions'
import TabViewContent from './tab-view-content.vue'
import TabViewItem from './tab-view-item.vue'
import TabViewList from './tab-view-list.vue'
Expand Down Expand Up @@ -39,6 +40,21 @@ const Tree = defineComponent({
})

describe('TabView (composition)', () => {
// The panel slide offset is a Tailwind `translate-x-*` utility, which in v4 compiles to
// the standalone `translate` property — NOT to `transform`. If `translate` is dropped
// from this list the offset stops interpolating and the swap pops instead of sliding,
// in this component and in Calendar's month strip, which shares the preset. <Transition>
// is stubbed by Vue Test Utils, so the rendered animation cannot be asserted; this
// guards the css contract behind it. Verified in real Chromium: with only
// `transform, opacity`, a mid-flight sample is already at its final value.
describe('panel motion contract', () => {
it('transitions translate (not just transform) so translate-x offsets interpolate', () => {
const { transition } = getTabViewPanelTransitionStyle()
expect(transition).toContain('translate ')
expect(transition).toContain('opacity ')
})
})

describe('compound API (index.js dot-notation)', () => {
it('attaches every public sub-component to the root compound', () => {
// Grounded in index.js: TabView === TabViewRoot with Root/List/Item/
Expand Down
Loading