/* ── Provider — variant-shared base ─────────────────────────────────────── */ [data-tabs] { --_tabs-trigger-height: var(--tabs-trigger-height-md); --_tabs-trigger-padding-inline: var(--tabs-trigger-padding-inline-md); --_tabs-trigger-gap: var(--tabs-trigger-gap-md); --_tabs-trigger-font-size: var(--tabs-trigger-font-size-md); --_tabs-content-padding: var(--tabs-content-padding-md); --_tabs-stack-gap: var(--tabs-stack-gap); display: flex; flex-direction: column; gap: var(--_tabs-stack-gap); min-width: 0; } [data-tabs][data-orientation='vertical'] { flex-direction: row; align-items: stretch; } /* ── Sizes ──────────────────────────────────────────────────────────────── */ [data-tabs][data-size='xs'] { --_tabs-trigger-height: var(--tabs-trigger-height-xs); --_tabs-trigger-padding-inline: var(--tabs-trigger-padding-inline-xs); --_tabs-trigger-gap: var(--tabs-trigger-gap-xs); --_tabs-trigger-font-size: var(--tabs-trigger-font-size-xs); --_tabs-content-padding: var(--tabs-content-padding-xs); } [data-tabs][data-size='sm'] { --_tabs-trigger-height: var(--tabs-trigger-height-sm); --_tabs-trigger-padding-inline: var(--tabs-trigger-padding-inline-sm); --_tabs-trigger-gap: var(--tabs-trigger-gap-sm); --_tabs-trigger-font-size: var(--tabs-trigger-font-size-sm); --_tabs-content-padding: var(--tabs-content-padding-sm); } [data-tabs][data-size='lg'] { --_tabs-trigger-height: var(--tabs-trigger-height-lg); --_tabs-trigger-padding-inline: var(--tabs-trigger-padding-inline-lg); --_tabs-trigger-gap: var(--tabs-trigger-gap-lg); --_tabs-trigger-font-size: var(--tabs-trigger-font-size-lg); --_tabs-content-padding: var(--tabs-content-padding-lg); } /* ── List ───────────────────────────────────────────────────────────────── */ [data-tabs-list] { position: relative; display: flex; flex-wrap: wrap; gap: var(--tabs-list-gap); min-width: 0; /* border-box so a `fitted` list's intrinsic padding (pills / segmented track) is absorbed INSIDE width:100% — without it the band overflows its column by the padding (the right-edge asymmetry inside a popover). */ box-sizing: border-box; background: var(--tabs-list-bg); } [data-tabs-list][data-orientation='vertical'] { flex-direction: column; flex: 0 0 var(--tabs-list-width); align-items: stretch; } /* ── Trigger ────────────────────────────────────────────────────────────── */ [data-tabs-trigger] { position: relative; display: inline-flex; align-items: center; justify-content: center; gap: var(--_tabs-trigger-gap); min-height: var(--_tabs-trigger-height); padding-inline: var(--_tabs-trigger-padding-inline); border: var(--tabs-trigger-border-width) solid var(--tabs-trigger-border); border-radius: var(--tabs-trigger-radius); background: var(--tabs-trigger-bg); color: var(--tabs-trigger-fg); font-family: var(--tabs-trigger-font-family); font-size: var(--_tabs-trigger-font-size); font-weight: var(--tabs-trigger-font-weight); line-height: var(--tabs-trigger-line-height); letter-spacing: var(--tabs-trigger-letter-spacing); text-decoration: none; cursor: pointer; transition: background var(--tabs-trigger-transition-duration) var(--tabs-trigger-transition-ease), border-color var(--tabs-trigger-transition-duration) var(--tabs-trigger-transition-ease), color var(--tabs-trigger-transition-duration) var(--tabs-trigger-transition-ease), box-shadow var(--tabs-trigger-transition-duration) var(--tabs-trigger-transition-ease); } [data-tabs-trigger]:hover { background-image: linear-gradient(var(--state-hover), var(--state-hover)); color: var(--tabs-hover-trigger-fg); } [data-tabs-trigger]:focus-visible { /* Canonical focus ring — `outline` (HCM-safe, survives forced-colors, unlike the 2-layer box-shadow this replaced) with the shared focus tokens. Matches the framework-wide focus sweep; on filled variants (pills / segmented) it hugs the pill radius cleanly instead of reading as a heavy boxed input. */ outline: var(--tabs-trigger-focus-ring-width) solid var(--tabs-trigger-focus-ring-color); outline-offset: var(--tabs-trigger-focus-ring-offset); } [data-tabs-trigger][data-disabled], [data-tabs-trigger]:disabled { cursor: default; opacity: var(--tabs-disabled-opacity); } /* ── Content ────────────────────────────────────────────────────────────── */ [data-tabs-content] { min-width: 0; flex: 1 1 auto; padding: var(--_tabs-content-padding); border: var(--tabs-content-border-width) solid var(--tabs-content-border); border-radius: var(--tabs-content-radius); background: var(--tabs-content-bg); color: var(--tabs-content-fg); } /* ── Variant: line ──────────────────────────────────────────────────────── */ [data-tabs][data-variant='line'] { --_tabs-stack-gap: var(--tabs-line-stack-gap); } [data-tabs][data-variant='line'] [data-tabs-list] { padding: 0; border-bottom: var(--tabs-list-border-width) solid var(--tabs-list-border); } [data-tabs][data-variant='line'][data-orientation='vertical'] [data-tabs-list] { border-bottom: none; border-inline-end: var(--tabs-list-border-width) solid var(--tabs-list-border); padding-inline-end: var(--tabs-line-list-padding-inline-end); } [data-tabs][data-variant='line'] [data-tabs-trigger] { border-radius: 0; border-color: transparent; } [data-tabs][data-variant='line'] [data-tabs-trigger][data-state='active'] { color: var(--tabs-active-trigger-fg); } /* In `line` variant the underline is the indicator's job (when present) * — but if no indicator is rendered, fall back to a static border so the * active state remains visible. The indicator's styles (below) override * this when the consumer renders it. */ [data-tabs][data-variant='line']:not(:has([data-tabs-indicator])) [data-tabs-trigger][data-state='active'] { border-bottom-color: var(--tabs-active-trigger-border); } [data-tabs][data-variant='line'][data-orientation='vertical']:not(:has([data-tabs-indicator])) [data-tabs-trigger][data-state='active'] { border-bottom-color: transparent; border-inline-end-color: var(--tabs-active-trigger-border); } /* ── Variant: surface ───────────────────────────────────────────────────── */ [data-tabs][data-variant='surface'] { padding: var(--tabs-surface-provider-padding); border: var(--tabs-provider-border-width) solid var(--tabs-provider-border); border-radius: var(--tabs-provider-radius); background: var(--tabs-provider-bg); box-shadow: var(--tabs-surface-provider-shadow); } [data-tabs][data-variant='surface'] [data-tabs-list] { padding: var(--tabs-list-padding); border: var(--tabs-list-border-width) solid var(--tabs-list-border); border-radius: var(--tabs-list-radius); background: var(--tabs-surface-list-bg); } [data-tabs][data-variant='surface'] [data-tabs-trigger][data-state='active'] { background: var(--tabs-active-trigger-bg); color: var(--tabs-active-trigger-fg); border-color: var(--tabs-active-surface-trigger-border); } [data-tabs][data-variant='surface'] [data-tabs-content] { border-color: var(--tabs-surface-content-border); background: var(--tabs-surface-content-bg); } /* ── Variant: pills ─────────────────────────────────────────────────────── */ [data-tabs][data-variant='pills'] [data-tabs-list] { padding: var(--tabs-list-padding); border-radius: var(--tabs-list-radius); background: var(--tabs-pills-list-bg); } [data-tabs][data-variant='pills'] [data-tabs-trigger] { border-radius: var(--tabs-pills-trigger-radius); } /* In pills, the indicator (when present) IS the active background. * Without an indicator, fall back to a static active background. */ [data-tabs][data-variant='pills']:not(:has([data-tabs-indicator])) [data-tabs-trigger][data-state='active'] { background: var(--tabs-active-trigger-bg); color: var(--tabs-active-trigger-fg); border-color: var(--tabs-active-pills-trigger-border); box-shadow: var(--tabs-active-pills-trigger-shadow); } /* When the indicator IS present in pills, the active trigger keeps a * neutral bg but raises its color above the indicator's tint. */ [data-tabs][data-variant='pills']:has([data-tabs-indicator]) [data-tabs-trigger][data-state='active'] { color: var(--tabs-active-trigger-fg); } [data-tabs][data-variant='pills'] [data-tabs-content] { padding-inline: 0; padding-block: var(--tabs-pills-content-padding-block-start) 0; border: none; border-radius: 0; background: transparent; } /* ── Variant: segmented ────────────────────────────────────────────────────── * iOS / Untitled-UI segmented control. A filled, recessed track holds equal * segments; the active segment is a PRIMARY accent pill (`segmented-trigger- * bg-active` = primary-solid + on-solid text + subtle shadow), NOT an accent * border nor a near-invisible neutral pill — so it reads clearly in light AND * dark, not as a focus-ringed box. Both the active bg and the on-solid text * are themeable tokens (override `--tabs-active-segmented-trigger-bg` / * `-color-active` to retint or restore a neutral pill). The trigger radius is * CONCENTRIC with the track (`segmented-trigger-radius` = track radius − list * padding). Pair with `fitted` for equal-width segments. */ [data-tabs][data-variant='segmented'] [data-tabs-list] { gap: 0; padding: var(--tabs-list-padding); border-radius: var(--tabs-segmented-list-radius); background: var(--tabs-segmented-list-bg); } [data-tabs][data-variant='segmented'] [data-tabs-trigger] { border-radius: var(--tabs-segmented-trigger-radius); border-color: transparent; } /* Without an indicator, the active segment paints its own accent pill. */ [data-tabs][data-variant='segmented']:not(:has([data-tabs-indicator])) [data-tabs-trigger][data-state='active'] { background: var(--tabs-active-segmented-trigger-bg); color: var(--tabs-active-segmented-trigger-fg); box-shadow: var(--tabs-active-segmented-trigger-shadow); } /* With the indicator present, it IS the pill; the active trigger only raises * its label to the on-solid colour above the accent indicator. */ [data-tabs][data-variant='segmented']:has([data-tabs-indicator]) [data-tabs-trigger][data-state='active'] { color: var(--tabs-active-segmented-trigger-fg); } [data-tabs][data-variant='segmented'] [data-tabs-content] { padding-inline: 0; padding-block: var(--tabs-pills-content-padding-block-start) 0; border: none; border-radius: 0; background: transparent; } /* ── Indicator ────────────────────────────────────────────────────────────── * The wrapper writes inline `transform`, `width`, `height` based on the * active trigger's offset rect. The recipe handles the slide animation, * z-index, and per-variant appearance. */ /* Position + size come from soma's `--indicator-*` (the "soma positions, eidos shapes" contract); the variants below own the look and may pin a cross-axis dimension on top. Uses the independent `translate` property, not `transform`, so a per-variant `transform` never wipes the slide. Those four vars are soma's VALUE CHANNEL, not a theming surface: the `MeasuredIndicator` layer writes the active trigger's offset rect onto this element on every move, so a public `--tabs-indicator-width` on top would be a knob a theme could pin and thereby break the slide — the adjudication `navigation-menu` already carries for its own `indicator-w` (and the reason its `-x` was retired dead). What belongs to a theme is the indicator's FORM — thickness, fill, radius, shadow, the slide's duration and ease — and those are the keys below. `left` is PHYSICAL on purpose: soma measures `triggerRect.left - listRect.left`, an offset that grows rightwards whatever the direction. A logical `inset-inline-start` anchored the indicator to the RIGHT edge in RTL while the offset kept pointing right, landing it on the wrong tab (measured: 400px off, and RTL-1 could not see it because the offset used to be an INLINE transform written by JS, not a CSS declaration). In LTR the two are identical. */ [data-tabs-indicator] { position: absolute; inset-block-start: 0; left: 0; pointer-events: none; z-index: 0; translate: var(--indicator-x, 0px) var(--indicator-y, 0px); inline-size: var(--indicator-w, 0px); block-size: var(--indicator-h, 0px); transition: translate var(--tabs-indicator-transition-duration) var(--tabs-indicator-transition-ease), inline-size var(--tabs-indicator-transition-duration) var(--tabs-indicator-transition-ease), block-size var(--tabs-indicator-transition-duration) var(--tabs-indicator-transition-ease), opacity var(--duration-fast) var(--ease-out); will-change: translate, inline-size, block-size; } [data-tabs-indicator]:not([data-ready]) { opacity: 0; } /* `line` variant: indicator is a thin underline along the leading edge */ [data-tabs][data-variant='line'] [data-tabs-indicator] { background: var(--tabs-active-trigger-border); height: var(--tabs-indicator-line-thickness, 2px); inset-block-start: auto; inset-block-end: calc(var(--tabs-list-border-width) * -1); } [data-tabs][data-variant='line'][data-orientation='vertical'] [data-tabs-indicator] { width: var(--tabs-indicator-line-thickness, 2px); inset-inline-start: auto; inset-inline-end: calc(var(--tabs-list-border-width) * -1); height: auto; } /* When the recipe takes over indicator dimensions (line variant uses a * fixed thickness), the wrapper's inline width/height for the cross axis * are overridden by the rules above; the inline axis stays driven by JS. */ /* `pills` variant: indicator is the rounded active background */ [data-tabs][data-variant='pills'] [data-tabs-indicator] { background: var(--tabs-active-trigger-bg); border: var(--tabs-trigger-border-width) solid var(--tabs-active-pills-trigger-border); border-radius: var(--tabs-pills-trigger-radius); box-shadow: var(--tabs-active-pills-trigger-shadow); z-index: 0; } /* `segmented` variant: indicator is the PRIMARY accent pill (no accent * border), concentric with the track. */ [data-tabs][data-variant='segmented'] [data-tabs-indicator] { background: var(--tabs-active-segmented-trigger-bg); border-radius: var(--tabs-segmented-trigger-radius); box-shadow: var(--tabs-active-segmented-trigger-shadow); z-index: 0; } /* Triggers inside a list with an indicator need to layer above it so the * label is readable. */ [data-tabs-list]:has([data-tabs-indicator]) [data-tabs-trigger] { position: relative; z-index: 1; background: transparent; } [data-tabs-list]:has([data-tabs-indicator]) [data-tabs-trigger][data-state='active'] { background: transparent; } /* Surface variant doesn't use the indicator — hide if accidentally rendered. */ [data-tabs][data-variant='surface'] [data-tabs-indicator] { display: none; } /* ── Fitted ───────────────────────────────────────────────────────────────── * List fills inline width and triggers split it equally (flex: 1 1 0). * Wraps still work because flex-wrap is on the list — when wrapping * happens, each row's triggers re-distribute. */ [data-tabs][data-fitted] [data-tabs-list] { width: 100%; /* literal: identity — `fitted` IS "the list fills the inline axis"; a token here would be a knob that can only hold one value without unsaying the prop */ flex-wrap: nowrap; } [data-tabs][data-fitted] [data-tabs-trigger] { flex: 1 1 0; min-width: 0; } /* ── Scrollable list ─────────────────────────────────────────────────────── * Horizontal-only — vertical scroll is handled by the natural overflow * of the column. Edge fade signals scrollability. */ [data-tabs][data-scrollable]:not([data-orientation='vertical']) [data-tabs-list] { flex-wrap: nowrap; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: none; mask-image: linear-gradient( to right, transparent, black var(--tabs-scrollable-fade, 16px), black calc(100% - var(--tabs-scrollable-fade, 16px)), transparent ); } [data-tabs][data-scrollable] [data-tabs-list]::-webkit-scrollbar { display: none; } [data-tabs][data-scrollable] [data-tabs-trigger] { flex: 0 0 auto; scroll-snap-align: start; } /* ── Collapse vertical → horizontal at narrow widths ──────────────────────── * `data-collapsed` is written by the wrapper via ResizeObserver when the * provider is narrower than the `collapseAt` threshold. Selectors below * mirror the horizontal-orientation rules so a vertical tablist gracefully * adopts the horizontal layout on narrow widths. */ [data-tabs][data-collapsed][data-orientation='vertical'] { flex-direction: column; } [data-tabs][data-collapsed][data-orientation='vertical'] [data-tabs-list] { flex: 0 1 auto; flex-direction: row; align-items: center; } [data-tabs][data-collapsed][data-variant='line'][data-orientation='vertical'] [data-tabs-list] { border-inline-end: none; border-bottom: var(--tabs-list-border-width) solid var(--tabs-list-border); padding-inline-end: 0; } [data-tabs][data-collapsed][data-variant='line'][data-orientation='vertical'] [data-tabs-indicator] { width: auto; height: var(--tabs-indicator-line-thickness, 2px); inset-block-start: auto; inset-block-end: calc(var(--tabs-list-border-width) * -1); inset-inline-end: auto; inset-inline-start: 0; } /* ── Content motion presets ──────────────────────────────────────────────── * The active panel animates in; inactive panels are unmounted (default * lazy) or hidden (`keepMounted`). All presets respect prefers-reduced- * motion below. * * MATERIALS from the motion channel (registered, themeable keyframes) — the * trigger stays these recipe rules because tabs' own `data-motion` prop * ('fade'/'slide') × `data-orientation` selects the flavour, a per-component * axis the generic preset trigger doesn't model. The subtle 4px nudge feeds * the distance hook from the canonical scale. */ [data-tabs-content][data-motion='fade'][data-state='active'] { animation: fade-in var(--duration-fast) var(--ease-out) both; } [data-tabs-content][data-motion='slide'][data-state='active'][data-orientation='horizontal'] { --motion-distance-md: var(--motion-distance-sm); animation: slide-from-bottom var(--duration-normal) var(--ease-out) both, fade-in var(--duration-normal) var(--ease-out) both; } [data-tabs-content][data-motion='slide'][data-state='active'][data-orientation='vertical'] { --motion-distance-md: var(--motion-distance-sm); animation: slide-from-right var(--duration-normal) var(--ease-out) both, fade-in var(--duration-normal) var(--ease-out) both; } /* ── Reduced motion ───────────────────────────────────────────────────────── * Honour the user's OS preference: indicator snaps without slide, * content panels appear instantly. */ @media (prefers-reduced-motion: reduce) { [data-tabs-indicator] { transition: opacity var(--duration-fast) var(--ease-out); } [data-tabs-content][data-motion='fade'], [data-tabs-content][data-motion='slide'] { animation: none; } }