diff --git a/.ai/skills/migration-styling/references/tldr-component-css-guidelines.md b/.ai/skills/migration-styling/references/tldr-component-css-guidelines.md
index 2d3f2a33365..c74f6cfa95f 100644
--- a/.ai/skills/migration-styling/references/tldr-component-css-guidelines.md
+++ b/.ai/skills/migration-styling/references/tldr-component-css-guidelines.md
@@ -125,3 +125,36 @@ Keep selector specificity at or below `(0,1,0)`. If you need a compounded select
Only add `@media (forced-colors: active)` if browser defaults are not conveying correct semantic intent, and always put it at the end of the component stylesheet.
→ See [01_component-css#forced-colors-requirements](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md#forced-colors-requirements)
+
+### 8. Browser API selectors
+
+Prefer native CSS pseudo-classes over attribute selectors when one exists for the same state (`:host(:popover-open)` not `:host([open])`; `:host(:disabled)` not `:host([disabled])`). Use attribute selectors for custom attributes and ARIA states with no native pseudo-class.
+→ See [01_component-css#state-implementation-patterns](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md#state-implementation-patterns)
+
+### 9. Sub-element inheritance
+
+When a sub-element must always match a variant-driven property on the parent, use `inherit` rather than repeating the `var()` reference in each variant rule.
+→ See [01_component-css#variant-implementation-patterns](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md#variant-implementation-patterns)
+
+### 10. Compound pseudo-classes on `:host()` via CSS nesting
+
+CSS nesting inside a `:host([...])` rule — e.g. `&:dir(rtl)` — expands to `:host([...]):dir(rtl)`, which chains the pseudo-class **outside** the `:host()` argument. Browsers do not support this; the rule silently fails with no parse error.
+
+```css
+/* ❌ Silent failure: :dir(rtl) is outside :host() */
+:host([placement='start']:popover-open) {
+ &:dir(rtl) {
+ transform: translateX(1rem);
+ }
+}
+
+/* ✅ All conditions inside the :host() argument */
+:host(:dir(rtl)[placement='start']:popover-open) {
+ transform: translateX(1rem);
+}
+```
+
+**Exception**: when the parent selector targets a descendant (e.g. `:host([...]) .swc-Child`), nesting `&:dir(rtl)` correctly applies `:dir()` to the inner element — that is valid and fine.
+
+**Migration note**: `:dir()` is a common place this surfaces. Whenever you add `:dir()` to a `:host`-level rule, write a separate `:host(:dir(rtl)[...])` rule instead of nesting.
+→ See [05_anti-patterns#9](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md#9-nesting-compound-pseudo-classes-on-host-via-css-nesting)
diff --git a/1st-gen/tools/base/src/version.ts b/1st-gen/tools/base/src/version.ts
index 7bd6d0e773a..f918df20145 100644
--- a/1st-gen/tools/base/src/version.ts
+++ b/1st-gen/tools/base/src/version.ts
@@ -17,7 +17,7 @@
/**
* The version of the 1st-gen Spectrum Web Components library.
*/
-export const version = '1.12.0';
+export const version = '1.12.1';
/**
* The version of the core base package.
diff --git a/2nd-gen/packages/core/components/tooltip/Tooltip.base.ts b/2nd-gen/packages/core/components/tooltip/Tooltip.base.ts
index 1d4b0169690..75c4d9a0165 100644
--- a/2nd-gen/packages/core/components/tooltip/Tooltip.base.ts
+++ b/2nd-gen/packages/core/components/tooltip/Tooltip.base.ts
@@ -97,14 +97,19 @@ export abstract class TooltipBase extends SpectrumElement {
public triggerElement: HTMLElement | null = null;
/**
- * Whether to apply warm-up and cooldown timing (1500ms each) to hover and focus events.
+ * Duration in milliseconds of the warm-up delay before the tooltip shows on pointer hover.
+ * Set to `0` to show immediately on hover. Keyboard focus (`focusin` when `:focus-visible`)
+ * always shows the tooltip immediately regardless of this value. The cooldown duration (before the next hover must wait again)
+ * matches this value. Warm-up/cooldown state is shared across all tooltips in the same document,
+ * so moving quickly between adjacent triggers (e.g. a toolbar) shows each subsequent tooltip
+ * immediately after the first warm-up elapses.
*
* Additive/deferred: active when `HoverController` is integrated.
*
- * @default false
+ * @default 1500
*/
- @property({ type: Boolean, reflect: true })
- public delayed: boolean = false;
+ @property({ type: Number, reflect: true })
+ public delay: number = 1500;
/**
* When set, prevents automatic trigger wiring from responding to hover and focus events.
@@ -206,31 +211,40 @@ export abstract class TooltipBase extends SpectrumElement {
);
}
+ // Guards dispatchAfterEvent so only the first transitionend per open/close cycle fires,
+ // preventing one after event from firing for each CSS property that transitions.
+ private afterEventPending = false;
+
private readonly handleBeforeToggle = (event: Event): void => {
const { newState } = event as ToggleEvent;
const eventName = newState === 'open' ? 'swc-open' : 'swc-close';
this.dispatchEvent(
new CustomEvent(eventName, { bubbles: true, composed: true })
);
+ // Set here so the flag is set regardless of whether this.open already matches
+ // newState. handleToggle exits early when open was set externally, which would
+ // otherwise leave the flag unset and suppress swc-after-open / swc-after-close.
+ this.afterEventPending = true;
};
private readonly handleToggle = (event: Event): void => {
const { newState } = event as ToggleEvent;
const isOpen = newState === 'open';
- if (isOpen === this.open) {
- return;
+ if (isOpen !== this.open) {
+ this.open = isOpen;
}
- this.open = isOpen;
// When no CSS transition is active, dispatch after-* immediately since transitionend will not fire.
if (getComputedStyle(this).transitionDuration === '0s') {
+ this.afterEventPending = false;
this.dispatchAfterEvent(isOpen);
}
};
private readonly handleTransitionEnd = (event: TransitionEvent): void => {
- if (event.target !== this) {
+ if (event.target !== this || !this.afterEventPending) {
return;
}
+ this.afterEventPending = false;
this.dispatchAfterEvent(this.open);
};
diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts
index 73e4673e0ab..49abc731597 100644
--- a/2nd-gen/packages/swc/.storybook/preview.ts
+++ b/2nd-gen/packages/swc/.storybook/preview.ts
@@ -330,7 +330,10 @@ const preview = {
'Rendering and styling migration analysis',
],
'Action button',
- ['Rendering and styling migration analysis'],
+ [
+ 'Accessibility migration analysis',
+ 'Rendering and styling migration analysis',
+ ],
'Action group',
['Rendering and styling migration analysis'],
'Action menu',
@@ -360,9 +363,14 @@ const preview = {
'Rendering and styling migration analysis',
],
'Button group',
- ['Rendering and styling migration analysis'],
+ [
+ 'Accessibility migration analysis',
+ 'Rendering and styling migration analysis',
+ ],
'Checkbox',
['Rendering and styling migration analysis'],
+ 'Close button',
+ ['Accessibility migration analysis'],
'Color field',
['Rendering and styling migration analysis'],
'Color loupe',
@@ -391,12 +399,16 @@ const preview = {
'Rendering and styling migration analysis',
],
'Infield button',
- ['Rendering and styling migration analysis'],
+ [
+ 'Accessibility migration analysis',
+ 'Rendering and styling migration analysis',
+ ],
'Infield progress circle',
['Rendering and styling migration analysis'],
'Link',
[
'Accessibility migration analysis',
+ 'Migration plan',
'Rendering and styling migration analysis',
],
'Menu',
diff --git a/2nd-gen/packages/swc/components/button/button-base.css b/2nd-gen/packages/swc/components/button/button-base.css
index 20eccdad11e..609f6a5c269 100644
--- a/2nd-gen/packages/swc/components/button/button-base.css
+++ b/2nd-gen/packages/swc/components/button/button-base.css
@@ -18,6 +18,7 @@
/* @global-exclude: host display/alignment apply to the custom element wrapper, not the native element */
:host {
display: inline-block;
+ inline-size: fit-content;
vertical-align: top;
}
diff --git a/2nd-gen/packages/swc/components/tooltip/Tooltip.ts b/2nd-gen/packages/swc/components/tooltip/Tooltip.ts
index 0969a5f0c84..e9117f7c57d 100644
--- a/2nd-gen/packages/swc/components/tooltip/Tooltip.ts
+++ b/2nd-gen/packages/swc/components/tooltip/Tooltip.ts
@@ -26,6 +26,8 @@ import styles from './tooltip.css';
* Save your changes
*
* @slot - Text label displayed in the tooltip.
+ *
+ * @cssprop --swc-tooltip-background-color - Background color of the tooltip bubble. Defaults to the neutral background color token.
*/
export class Tooltip extends TooltipBase {
// ──────────────────────────────
@@ -38,8 +40,10 @@ export class Tooltip extends TooltipBase {
protected override render(): TemplateResult {
return html`
-
-
+
+
+
+
`;
}
}
diff --git a/2nd-gen/packages/swc/components/tooltip/stories/tooltip.stories.ts b/2nd-gen/packages/swc/components/tooltip/stories/tooltip.stories.ts
new file mode 100644
index 00000000000..ee33eb024e6
--- /dev/null
+++ b/2nd-gen/packages/swc/components/tooltip/stories/tooltip.stories.ts
@@ -0,0 +1,351 @@
+/**
+ * Copyright 2026 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+import { html } from 'lit';
+// Temporary: floating-ui used only in stories for basic positioning until
+// PlacementController and HoverController land in the additive phase.
+import { computePosition, type Placement } from '@floating-ui/dom';
+import type { Meta, StoryObj as Story } from '@storybook/web-components';
+import { getStorybookHelpers } from '@wc-toolkit/storybook-helpers';
+
+import {
+ TOOLTIP_PLACEMENTS,
+ TOOLTIP_VARIANTS,
+ type TooltipPlacement,
+ type TooltipVariant,
+} from '@spectrum-web-components/core/components/tooltip';
+
+import '@adobe/spectrum-wc/components/button/swc-button.js';
+import '@adobe/spectrum-wc/components/tooltip/swc-tooltip.js';
+
+// ────────────────────
+// METADATA SETUP
+// ────────────────────
+
+const { args, argTypes, template } = getStorybookHelpers('swc-tooltip');
+
+argTypes.variant = {
+ ...argTypes.variant,
+ control: { type: 'select' },
+ options: TOOLTIP_VARIANTS,
+};
+
+argTypes.placement = {
+ ...argTypes.placement,
+ control: { type: 'select' },
+ options: TOOLTIP_PLACEMENTS,
+};
+
+// ────────────────────
+// HELPERS
+// ────────────────────
+
+const variantLabels = {
+ neutral: 'Save your changes',
+ informative: 'File will be compressed',
+ negative: 'Action cannot be undone',
+} as const satisfies Record;
+
+const variantTriggerLabels = {
+ neutral: 'Save',
+ informative: 'Upload',
+ negative: 'Delete',
+} as const satisfies Record;
+
+const placementLabels = {
+ top: 'Appears above',
+ right: 'Appears to the right',
+ end: 'Appears at end',
+ bottom: 'Appears below',
+ left: 'Appears to the left',
+ start: 'Appears at start',
+} as const satisfies Record;
+
+const TABULAR_PLACEMENTS: TooltipPlacement[] = [
+ 'top',
+ 'right',
+ 'end',
+ 'bottom',
+ 'left',
+ 'start',
+];
+
+// SWC uses logical 'start'/'end'; map to physical values for Floating UI.
+const toFloatingPlacement = (placement: string): Placement => {
+ if (placement === 'start') {
+ if (document.dir === 'rtl') {
+ return 'right';
+ }
+ return 'left';
+ }
+ if (placement === 'end') {
+ if (document.dir === 'rtl') {
+ return 'left';
+ }
+ return 'right';
+ }
+ return placement as Placement;
+};
+
+// Temporary: positions the tooltip via Floating UI once the popover appears in the
+// top layer.
+const positionTooltip = (button: HTMLElement, tooltip: HTMLElement): void => {
+ const placement = tooltip.getAttribute('placement') ?? 'top';
+ void computePosition(button, tooltip, {
+ placement: toFloatingPlacement(placement),
+ }).then(({ x, y }) => {
+ Object.assign(tooltip.style, {
+ left: `${x}px`,
+ top: `${y}px`,
+ });
+ });
+};
+
+// Temporary: toggles `open` on the linked tooltip on click until HoverController is
+// available. Waits for the `toggle` event before measuring so Floating UI sees the
+// element after showPopover() places it in the top layer.
+const makeToggle = (id: string) => (event: MouseEvent) => {
+ const root = (event.currentTarget as HTMLElement).getRootNode() as
+ | Document
+ | ShadowRoot;
+ const button = event.currentTarget as HTMLElement;
+ const tooltip = root.querySelector(
+ `swc-tooltip[for="${id}"]`
+ ) as HTMLElement & { open: boolean };
+
+ if (!tooltip) {
+ return;
+ }
+
+ setupEventLogger(tooltip);
+ tooltip.open = !tooltip.open;
+
+ if (tooltip.open) {
+ tooltip.addEventListener(
+ 'toggle',
+ (toggleEvent) => {
+ if ((toggleEvent as ToggleEvent).newState !== 'open') {
+ return;
+ }
+ positionTooltip(button, tooltip);
+ },
+ { once: true }
+ );
+ }
+};
+
+// Temporary: logs tooltip lifecycle events to the console to verify event wiring.
+// Replace with proper assertions in migration-testing (Phase 6).
+// Storybook's Actions addon doesn't work well for this since the events are re-dispatched
+// from the popover in the top layer, so we log directly from the component instance instead.
+const loggedTooltips = new WeakSet();
+const setupEventLogger = (tooltip: Element): void => {
+ if (loggedTooltips.has(tooltip)) {
+ return;
+ }
+ loggedTooltips.add(tooltip);
+ for (const name of [
+ 'swc-open',
+ 'swc-close',
+ 'swc-after-open',
+ 'swc-after-close',
+ ]) {
+ tooltip.addEventListener(name, () => {
+ console.log(`[swc-tooltip] ${name}`);
+ });
+ }
+};
+
+// Renders a button+tooltip pair linked via the `for` attribute.
+// Each pair needs a unique `id` so multiple instances can coexist in the same story.
+const triggered = (
+ tooltipArgs: Record,
+ id: string,
+ buttonLabel: string
+) => html`
+ ${buttonLabel}
+ ${template({ ...tooltipArgs, for: id })}
+`;
+
+/**
+ * Each story renders one or more buttons that trigger associated tooltips when clicked.
+ * These stories use a temporary click-to-toggle implementation until `HoverController` is available in the additive phase.
+ */
+const meta: Meta = {
+ title: 'Tooltip',
+ component: 'swc-tooltip',
+ parameters: {
+ docs: {
+ subtitle: `Brief contextual message that appears near a trigger element.`,
+ },
+ },
+ args,
+ argTypes,
+ render: (args) => {
+ return html`
+ ${triggered({ ...args }, 'swc-tooltip-default-trigger', 'Open')}
+ `;
+ },
+ tags: ['migrated'],
+};
+
+export default meta;
+
+// ────────────────────
+// AUTODOCS STORY
+// ────────────────────
+
+export const Playground: Story = {
+ tags: ['autodocs', 'dev'],
+ args: {
+ variant: 'neutral',
+ placement: 'top',
+ 'default-slot': variantLabels.neutral,
+ },
+};
+
+// ──────────────────────────────
+// OVERVIEW STORIES
+// ──────────────────────────────
+
+export const Overview: Story = {
+ tags: ['overview'],
+ args: {
+ variant: 'neutral',
+ placement: 'top',
+ 'default-slot': 'Save your changes',
+ },
+};
+
+// ──────────────────────────
+// ANATOMY STORIES
+// ──────────────────────────
+
+/**
+ * A tooltip consists of:
+ *
+ * 1. **Tooltip bubble**: Container with rounded corners and variant-specific background color
+ * 2. **Tip indicator**: Triangular arrow pointing toward the trigger element (placement-aware)
+ * 3. **Default slot**: Text content displayed inside the bubble
+ *
+ * Click each button below to toggle short and long text variants.
+ */
+export const Anatomy: Story = {
+ render: (args) => html`
+ ${triggered({ ...args }, 'tooltip-anatomy-short', 'Action')}
+ ${triggered(
+ {
+ ...args,
+ 'default-slot':
+ 'Longer tooltip text needs to wrap across multiple lines when the content exceeds the maximum inline size',
+ },
+ 'tooltip-anatomy-long',
+ 'Another action'
+ )}
+ `,
+ args: {
+ placement: 'top',
+ 'default-slot': 'Short label',
+ },
+ tags: ['anatomy'],
+ parameters: { flexLayout: 'row-wrap' },
+};
+
+// ──────────────────────────
+// OPTIONS STORIES
+// ──────────────────────────
+export const Variants: Story = {
+ render: (args) => html`
+ ${TOOLTIP_VARIANTS.map((variant) =>
+ triggered(
+ { ...args, variant, 'default-slot': variantLabels[variant] },
+ `tooltip-trigger-${variant}`,
+ variantTriggerLabels[variant]
+ )
+ )}
+ `,
+ tags: ['options'],
+ parameters: { flexLayout: 'row-wrap', 'section-order': 1 },
+};
+
+/**
+ * The `placement` attribute sets the preferred position of the tooltip relative to its trigger.
+ * Pixel-accurate anchoring requires `PlacementController` (additive phase); these stories
+ * temporarily use Floating UI directly to verify visual appearance across placements.
+ */
+export const Placements: Story = {
+ render: (args) => html`
+
+
+ `,
+ tags: ['options'],
+ parameters: {
+ layout: 'padded',
+ 'section-order': 2,
+ docs: {
+ canvas: {
+ sourceState: 'hidden',
+ },
+ },
+ },
+};
+
+// ──────────────────────────
+// STATES STORIES
+// ──────────────────────────
+
+// TODO: will complete in separate documentation pass of phase 7
+
+// ────────────────────────────────
+// ACCESSIBILITY STORIES
+// ────────────────────────────────
+
+// TODO: will complete in separate documentation pass of phase 7
diff --git a/2nd-gen/packages/swc/components/tooltip/tooltip.css b/2nd-gen/packages/swc/components/tooltip/tooltip.css
index e60f6801fe4..29bf282ddb3 100644
--- a/2nd-gen/packages/swc/components/tooltip/tooltip.css
+++ b/2nd-gen/packages/swc/components/tooltip/tooltip.css
@@ -10,6 +10,287 @@
* governing permissions and limitations under the License.
*/
-/* Placeholder — CSS implementation is Phase 5. */
+/* ─────────────────────────────────────────────────────────────────────────────
+ HOST
+ Reset browser UA popover styles. All visual styles live on .swc-Tooltip.
+ PlacementController (additive phase) will apply inset/left/top for
+ positioning.
+ ───────────────────────────────────────────────────────────────────────────── */
-/* Source: spectrum-css/components/tooltip/index.css (spectrum-two branch). */
+:host {
+ --_swc-tooltip-animation-distance: token("spacing-75");
+
+ position: absolute;
+ inset: auto;
+ max-inline-size: min(100%, token("tooltip-maximum-width"));
+ padding: 0;
+ margin: 0;
+ color: unset;
+ background: transparent;
+ border: none;
+ overflow: visible;
+ opacity: 0; /* EXIT animation state */
+ transition-timing-function: ease-in-out;
+ transition-duration: token("animation-duration-100");
+ transition-property: transform, opacity, overlay, display;
+ transition-behavior: allow-discrete;
+}
+
+/* Extra protection against accidental consumer style side effects */
+:host(:not(:popover-open)) {
+ display: none;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+/* ─────────────────────────────────────────────────────────────────────────────
+ BASE TOOLTIP
+ ───────────────────────────────────────────────────────────────────────────── */
+
+.swc-Tooltip {
+ --_swc-tooltip-tip-height: token("tooltip-tip-height");
+ --_swc-tooltip-background-color: token("neutral-background-color-default");
+ --_swc-tooltip-tip-square-size: 8px;
+ --_swc-tooltip-border-radius: token("corner-radius-400");
+
+ display: inline-flex;
+ position: relative;
+ min-block-size: token("component-height-75");
+ padding-block: token("component-padding-vertical-75");
+ padding-inline: token("component-edge-to-text-75");
+ margin-block-end: var(--_swc-tooltip-tip-height);
+ font-size: token("font-size-75");
+ font-weight: token("regular-font-weight");
+ line-height: token("line-height-font-size-75");
+ color: token("gray-25");
+ overflow-wrap: break-word;
+ background-color: var(--swc-tooltip-background-color, var(--_swc-tooltip-background-color));
+ border-radius: var(--_swc-tooltip-border-radius);
+
+ /* CJK line-height support */
+ &:lang(ja),
+ &:lang(zh),
+ &:lang(ko) {
+ line-height: token("cjk-line-height-100");
+ }
+}
+
+/* ─────────────────────────────────────────────────────────────────────────────
+ VARIANTS
+ ───────────────────────────────────────────────────────────────────────────── */
+
+:host([variant="informative"]) {
+ --swc-tooltip-background-color: token("informative-background-color-default");
+}
+
+:host([variant="negative"]) {
+ --swc-tooltip-background-color: token("negative-background-color-default");
+}
+
+/* ─────────────────────────────────────────────────────────────────────────────
+ PLACEMENT MARGINS
+ Space between the tip arrow and the trigger; based on tip-height token.
+ These margins create gap when PlacementController computes position.
+ NOTE: top / default margin lives in .swc-Tooltip above to avoid
+ re-declaring .swc-Tooltip after the nested lang rules.
+ ───────────────────────────────────────────────────────────────────────────── */
+
+/* Bottom: margin at block-start */
+:host([placement="bottom"]) .swc-Tooltip,
+:host([placement="bottom-start"]) .swc-Tooltip,
+:host([placement="bottom-end"]) .swc-Tooltip {
+ margin-block-start: var(--_swc-tooltip-tip-height);
+ margin-block-end: 0;
+}
+
+/* Right: margin at inline-start */
+:host([placement="right"]) .swc-Tooltip,
+:host([placement="right-top"]) .swc-Tooltip,
+:host([placement="right-bottom"]) .swc-Tooltip {
+ margin-block-end: 0;
+ margin-left: var(--_swc-tooltip-tip-height);
+}
+
+/* Left: margin at inline-end */
+:host([placement="left"]) .swc-Tooltip,
+:host([placement="left-top"]) .swc-Tooltip,
+:host([placement="left-bottom"]) .swc-Tooltip {
+ margin-block-end: 0;
+ margin-right: var(--_swc-tooltip-tip-height);
+}
+
+/* Start: margin at inline-end (tooltip is before the trigger) */
+:host([placement="start"]) .swc-Tooltip,
+:host([placement="start-top"]) .swc-Tooltip,
+:host([placement="start-bottom"]) .swc-Tooltip {
+ margin-block-end: 0;
+ margin-inline-end: var(--_swc-tooltip-tip-height);
+}
+
+/* End: margin at inline-start (tooltip is after the trigger) */
+:host([placement="end"]) .swc-Tooltip,
+:host([placement="end-top"]) .swc-Tooltip,
+:host([placement="end-bottom"]) .swc-Tooltip {
+ margin-block-end: 0;
+ margin-inline-start: var(--_swc-tooltip-tip-height);
+}
+
+/* ─────────────────────────────────────────────────────────────────────────────
+ OPEN STATE
+ ───────────────────────────────────────────────────────────────────────────── */
+
+/* Default / top placement: tooltip appears above trigger, animates upward */
+:host(:popover-open) {
+ opacity: 1;
+ transform: translateY(calc(-1 * var(--_swc-tooltip-animation-distance)));
+}
+
+@starting-style {
+ :host(:popover-open) {
+ opacity: 0;
+ transform: translateY(0);
+ }
+}
+
+/* Bottom placement: tooltip appears below trigger, animates downward */
+:host([placement="bottom"]:popover-open) {
+ transform: translateY(var(--_swc-tooltip-animation-distance));
+}
+
+/* Right placement: tooltip appears to the right, animates rightward */
+:host([placement="right"]:popover-open) {
+ transform: translateX(var(--_swc-tooltip-animation-distance));
+}
+
+/* Left placement: tooltip appears to the left, animates leftward */
+:host([placement="left"]:popover-open) {
+ transform: translateX(calc(-1 * var(--_swc-tooltip-animation-distance)));
+}
+
+/* Start placement: LTR = leftward, RTL = rightward */
+:host([placement="start"]:popover-open) {
+ transform: translateX(calc(-1 * var(--_swc-tooltip-animation-distance)));
+}
+
+:host(:dir(rtl)[placement="start"]:popover-open) {
+ transform: translateX(var(--_swc-tooltip-animation-distance));
+}
+
+/* End placement: LTR = rightward, RTL = leftward */
+:host([placement="end"]:popover-open) {
+ transform: translateX(var(--_swc-tooltip-animation-distance));
+}
+
+:host(:dir(rtl)[placement="end"]:popover-open) {
+ transform: translateX(calc(-1 * var(--_swc-tooltip-animation-distance)));
+}
+
+@starting-style {
+ :host([placement="right"]:popover-open),
+ :host([placement="left"]:popover-open),
+ :host([placement="start"]:popover-open),
+ :host([placement="end"]:popover-open) {
+ transform: translateX(0);
+ }
+}
+
+/* ─────────────────────────────────────────────────────────────────────────────
+ TIP ELEMENT
+ A rotated square with clip-path creates the directional arrow.
+ ───────────────────────────────────────────────────────────────────────────── */
+
+/* Default: tip at bottom edge, pointing down ▽ (for top-placement tooltips) */
+.swc-Tooltip-tip {
+ position: absolute;
+ top: calc(100% - (0.5 * var(--_swc-tooltip-tip-square-size)) - 0.5px);
+ left: calc(50% - (0.5 * var(--_swc-tooltip-tip-square-size)));
+ inline-size: var(--_swc-tooltip-tip-square-size);
+ block-size: var(--_swc-tooltip-tip-square-size);
+ background-color: inherit;
+ border-radius: 0 0 0 token("tooltip-tip-corner-radius");
+ clip-path: polygon(0 0, 100% 100%, 0 100%);
+ transform: rotate(-45deg);
+}
+
+/* ── Tip placement ── */
+
+/* Top placements: tip at bottom edge pointing down ▽ (same as default; listed for clarity) */
+:host([placement="top"]) .swc-Tooltip-tip {
+ top: calc(100% - (0.5 * var(--_swc-tooltip-tip-square-size)) - 0.5px);
+ transform: rotate(-45deg);
+}
+
+/* Bottom placements: tip at top edge pointing up △ */
+:host([placement="bottom"]) .swc-Tooltip-tip {
+ inset-block: auto calc(100% - (0.5 * var(--_swc-tooltip-tip-square-size)) - 0.5px);
+ transform: rotate(135deg);
+}
+
+/* Right placement (physical): tip at physical left edge pointing left ◁
+ Must use a physical property so the tip does not flip in RTL. */
+:host([placement="right"]) .swc-Tooltip-tip {
+ top: calc(50% - (0.5 * var(--_swc-tooltip-tip-square-size)));
+ left: calc(-0.5 * var(--_swc-tooltip-tip-square-size) + 0.5px);
+ transform: rotate(45deg);
+}
+
+/* End placement (logical): tip at logical inline-end edge, flips naturally in RTL.
+ inset-inline: auto X resets the base `left` (via inset-inline-start: auto) and
+ sets the correct edge via inset-inline-end — no position override needed in RTL. */
+:host([placement="end"]) .swc-Tooltip-tip {
+ inset-block-start: calc(50% - (0.5 * var(--_swc-tooltip-tip-square-size)));
+ inset-inline: auto calc(100% - (0.5 * var(--_swc-tooltip-tip-square-size)) - 0.5px);
+ transform: rotate(45deg); /* LTR: points left ◁ */
+
+ &:dir(rtl) {
+ transform: rotate(-135deg); /* RTL: end = left side, tip points right ▷ */
+ }
+}
+
+/* Left placement (physical): tip at physical right edge pointing right ▷
+ Must use physical properties so the tip does not flip in RTL. */
+:host([placement="left"]) .swc-Tooltip-tip {
+ top: calc(50% - (0.5 * var(--_swc-tooltip-tip-square-size)));
+ right: calc(-0.5 * var(--_swc-tooltip-tip-square-size) + 0.5px);
+ left: auto;
+ transform: rotate(-135deg);
+}
+
+/* Start placement (logical): tip at logical inline-start edge, flips naturally in RTL.
+ In RTL inset-inline-start maps to right; the base physical left is then overridden
+ by the browser (right wins over left for RTL absolutely-positioned elements). */
+:host([placement="start"]) .swc-Tooltip-tip {
+ inset-block-start: calc(50% - (0.5 * var(--_swc-tooltip-tip-square-size)));
+ inset-inline-start: calc(100% - (0.5 * var(--_swc-tooltip-tip-square-size)) - 0.5px);
+ transform: rotate(-135deg); /* LTR: points right ▷ */
+
+ &:dir(rtl) {
+ transform: rotate(45deg); /* RTL: start = right side, tip points left ◁ */
+ }
+}
+
+/* ─────────────────────────────────────────────────────────────────────────────
+ LABEL
+ ───────────────────────────────────────────────────────────────────────────── */
+
+::slotted(*:not([class])) {
+ margin: 0 !important;
+ font: inherit !important;
+ color: inherit !important;
+}
+
+/* ─────────────────────────────────────────────────────────────────────────────
+ FORCED COLORS (HIGH CONTRAST MODE)
+ ───────────────────────────────────────────────────────────────────────────── */
+
+@media (forced-colors: active) {
+ .swc-Tooltip {
+ border: 1px solid CanvasText;
+ }
+
+ .swc-Tooltip-tip {
+ background-color: CanvasText;
+ }
+}
diff --git a/2nd-gen/packages/swc/vite.config.ts b/2nd-gen/packages/swc/vite.config.ts
index 2ef5bc249a8..0dfcb1d232f 100644
--- a/2nd-gen/packages/swc/vite.config.ts
+++ b/2nd-gen/packages/swc/vite.config.ts
@@ -35,6 +35,7 @@ const postcssPlugins = [
'logical-properties-and-values': false,
'is-pseudo-class': false,
'cascade-layers': false,
+ 'dir-pseudo-class': false,
},
}),
];
diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md
index 02e071e8cff..382489c58de 100644
--- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md
+++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/01_component-css.md
@@ -262,6 +262,14 @@ Variants change how the component looks. Use the right selector based on customi
}
```
+**Sub-elements that track a variant value**: use `inherit` on the sub-element rather than repeating the override in every variant rule. Since the sub-element is a descendant, `inherit` copies the computed value from the parent.
+
+```css
+.swc-Tooltip-tip {
+ background-color: inherit; /* always matches .swc-Tooltip's variant color */
+}
+```
+
## State implementation patterns
States reflect user interaction or component condition. Attach them to `:host` when the host element carries the state.
@@ -275,6 +283,8 @@ States reflect user interaction or component condition. Attach them to `:host` w
**Why**: States on `:host` let consumers style `swc-badge[disabled]` or `swc-badge:focus-visible`. If the state lives on an internal element, target that element directly.
+**Prefer native pseudo-classes**: when the browser exposes a pseudo-class that maps to the same state, use it instead of the attribute selector. `:host(:popover-open)` is correct for a component using `popover="auto"`; `:host(:disabled)` is correct where the host element carries the disabled state natively. The pseudo-class reflects actual browser state rather than a synced property.
+
**Note**: Badge and Status Light are non-interactive, so they do not define focus or disabled states. See interactive components (e.g. Button) for examples.
**Derived states are not on `:host`**: If a state is computed from slot content (e.g. icon-only), it is not a consumer-settable attribute and must not appear in the state table above. Express it as a class modifier on the internal element via `classMap`. See [When to use classes vs attributes](#when-to-use-classes-vs-attributes).
diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md
index f3a628fc5c6..1b40e12d734 100644
--- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md
+++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md
@@ -105,6 +105,21 @@ When a private property captures a token value, reference the private property i
This keeps all overrides and derived calculations linked to the private property. If the definition ever changes, every downstream call updates automatically.
+A private property can also alias another private property when the same value serves two logically distinct roles. The alias name captures the concept at the usage site; the single definition point keeps them in sync.
+
+```css
+.swc-Tooltip {
+ --_swc-tooltip-border-radius: token("corner-radius-400");
+ --_swc-tooltip-tip-corner-offset: var(--_swc-tooltip-border-radius);
+
+ border-radius: var(--_swc-tooltip-border-radius);
+}
+
+:host([placement="top-start"]) .swc-Tooltip-tip {
+ inset-inline-start: var(--_swc-tooltip-tip-corner-offset);
+}
+```
+
## Component Custom Property Exposure
**Selector choice encodes API intent**: exposed properties are modified via `:host()`, while internal-only behavior is implemented with internal class selectors.
diff --git a/CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md b/CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md
index a0ca1c44d96..dd30247d459 100644
--- a/CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md
+++ b/CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md
@@ -56,6 +56,11 @@
- [Specificity escalation → `:where()`](#specificity-escalation--where)
- [Size classes in render → `:host([size])`](#size-classes-in-render--hostsize)
- [`--mod-*` chain → single property](#--mod--chain--single-property)
+- [9. Nesting compound pseudo-classes on `:host()` via CSS nesting](#9-nesting-compound-pseudo-classes-on-host-via-css-nesting)
+ - [❌ Anti-Pattern](#-anti-pattern)
+ - [Why This Happens](#why-this-happens)
+ - [Why This Is a Problem](#why-this-is-a-problem)
+ - [✅ Correct Approach](#-correct-approach)
- [Final Reminder](#final-reminder)
@@ -419,6 +424,64 @@ After migration, Badge relies solely on `.swc-Badge` and attributes.
| ------------------------------------------------------- | -------------------------------------------------------- |
| `var(--mod-badge-height, var(--spectrum-badge-height))` | `var(--swc-badge-height, token("component-height-100"))` |
+## 9. Nesting compound pseudo-classes on `:host()` via CSS nesting
+
+### ❌ Anti-Pattern
+
+```css
+/* Intends to target the host in RTL when placement="start" is open */
+:host([placement="start"]:popover-open) {
+ transform: translateX(calc(-1 * var(--_swc-component-animation-distance)));
+
+ &:dir(rtl) {
+ transform: translateX(var(--_swc-component-animation-distance));
+ }
+}
+```
+
+### Why This Happens
+
+CSS nesting with `&` replaces `&` with the parent selector. Inside a `:host([...])` rule, `&:dir(rtl)` expands to `:host([...]):dir(rtl)` — a pseudo-class chained after the `:host()` function. This looks syntactically correct, but browsers do not support compound selectors appended outside of the `:host()` argument.
+
+### Why This Is a Problem
+
+- The rule silently fails: the `:dir()` override never applies
+- No lint or parse error is produced, making it hard to detect
+- Properties meant for RTL layout apply in all directions
+
+### ✅ Correct Approach
+
+Move all conditions inside the `:host()` argument as a compound selector:
+
+```css
+:host([placement="start"]:popover-open) {
+ transform: translateX(calc(-1 * var(--_swc-component-animation-distance)));
+}
+
+:host(:dir(rtl)[placement="start"]:popover-open) {
+ transform: translateX(var(--_swc-component-animation-distance));
+}
+```
+
+#### Exception: descendants are fine
+
+This restriction only applies when `:host()` is the outermost element being targeted. When nesting targets a **descendant** of the host, expanding `&:dir(rtl)` applies `:dir()` to the inner element — which is valid:
+
+```css
+/* ✅ Fine: :dir(rtl) targets .swc-Component-tip, not :host() */
+:host([placement="end"]) .swc-Component-tip {
+ transform: rotate(45deg);
+
+ &:dir(rtl) {
+ transform: rotate(-135deg);
+ }
+}
+```
+
+#### Migration note: `:dir()` in RTL-aware components
+
+`:dir()` is the most common pseudo-class where this issue surfaces during migrations because RTL overrides are nearly always added after a component's base styles are written. When adding `:dir()` to any `:host`-level rule during migration, always write it as a separate `:host(:dir(rtl)[...])` rule rather than a nested `&:dir(rtl)`.
+
## Final Reminder
If you find yourself:
diff --git a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md
index 3394bbb832a..94972932099 100644
--- a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md
+++ b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md
@@ -86,7 +86,7 @@
| Textfield | ✓ | | | | | | |
| Thumbnail | ✓ | | | | | | |
| Toast | | | | | | | |
-| Tooltip | ✓ | ✓ | ✓ | ✓ | | | |
+| Tooltip | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Top Nav | | | | | | | |
| Tray | | | | | | | |
| Underlay | | | | | | | |
diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/tooltip/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/tooltip/migration-plan.md
index de3c4bcb804..306ddf17aaa 100644
--- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/tooltip/migration-plan.md
+++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/tooltip/migration-plan.md
@@ -49,6 +49,11 @@
- [Review](#review)
- [Blockers and open questions](#blockers-and-open-questions)
- [Deferred implementation tickets](#deferred-implementation-tickets)
+- [Addendum: HoverController interface requirements](#addendum-hovercontroller-interface-requirements)
+ - [Scope](#scope)
+ - [Warm-up / cooldown state machine](#warm-up--cooldown-state-machine)
+ - [Warm state storage](#warm-state-storage)
+ - [Warm state scoping across component types](#warm-state-scoping-across-component-types)
- [References](#references)
@@ -70,7 +75,7 @@ Tooltip is a visually simple component with high behavioral complexity in its au
- **Breaking (B5):** Event renames: `sp-opened`/`sp-closed` removed; 2nd-gen fires `swc-open`, `swc-after-open`, `swc-close`, `swc-after-close`. Event timing also differs due to native popover lifecycle; must document in consumer migration guide.
- **A11y critical (SWC-1558):** `role="tooltip"` is absent in 1st-gen; must ship in 2nd-gen.
- **Infrastructure change:** `sp-overlay` dependency dropped. 2nd-gen uses native popover API + Floating UI per the [Overlay Strategy RFC](https://www.dropbox.com/scl/fi/eae4rywxitn4zfmuw4o59/RFC-Overlay-strategy-for-1st-gen-and-2nd-gen.paper?rlkey=ljezd8mt8joy2zc3lv88usrh6&dl=0). The `self-managed` attribute is removed (B6); automatic trigger wiring is on by default; the `manual` attribute opts out. Internal mechanics change significantly.
-- **Automatic trigger integration is additive:** Deferred pending extraction of `PlacementController` (viewport-aware positioning) and `HoverController` (warm-up/cooldown timing, focus parity). Initial 2nd-gen tooltip ships `for` and `trigger-element` as active API — ARIA relationship wiring fires on `open` change from day one (see A4). Hover/focus event wiring and screen positioning require the controllers. `delayed`, `disabled`, and `manual` are in the API shape but inactive until the additive phase. Before scheduling the additive implementation ticket, confirm that both controllers have been extracted and are consumable per the Overlay RFC.
+- **Automatic trigger integration is additive:** Deferred pending extraction of `PlacementController` (viewport-aware positioning) and `HoverController` (warm-up/cooldown timing, focus parity). Initial 2nd-gen tooltip ships `for` and `trigger-element` as active API — ARIA relationship wiring fires on `open` change from day one (see A4). Hover/focus event wiring and screen positioning require the controllers. `delay`, `disabled`, and `manual` are in the API shape but inactive until the additive phase. Before scheduling the additive implementation ticket, confirm that both controllers have been extracted and are consumable per the Overlay RFC.
- **Authoring pattern change:** `` is authored as a sibling of the trigger — not inside it as in 1st-gen. With `popover="auto"` moving the tooltip to the top layer at render time, physical DOM nesting is no longer needed. Trigger resolution uses the `for` attribute to reference the trigger by ID in the same document tree root; `trigger-element` provides an element reference override for cross-shadow-root and programmatic cases where ID resolution does not apply. Add `manual` to opt out of automatic wiring entirely. The 1st-gen ancestor-walking (`resolveSelfManagedTriggerElement`) is not ported.
- **No open questions.** Q1 (`tip-padding`) and Q2 (`popover="auto"` stack isolation) are both resolved. See [Blockers and open questions](#blockers-and-open-questions).
@@ -230,7 +235,7 @@ Neither controller is available yet. The automatic trigger integration additive
| # | What is added | Notes |
| --- | ------------- | ----- |
| A1 | Automatic trigger integration (hover/focus) | `popover="auto"` and ARIA wiring are already active from the initial release. Additive work: wire `HoverController` (hover/focus events, warm-up/cooldown timing); wire `PlacementController` (pixel positioning). Skipped when `manual` is set. Blocked on both controller extractions. |
-| A2 | Warm-up / cooldown (`delayed`) | 1500ms warmup / 1500ms cooldown provided by `HoverController` (aligned with React Spectrum; 1st-gen was 1000ms). `delayed` attribute ships in the API but inactive until the additive phase. Blocked on `HoverController` extraction. |
+| A2 | Warm-up / cooldown (`delay`) | Warm-up/cooldown is the **default behavior** for all tooltips (not opt-in). `delay: number` (default 1500ms) sets the duration; `delay="0"` opts out. Ships in the API shape but inactive until the additive phase. Blocked on `HoverController` extraction. See [addendum](#addendum-hovercontroller-interface-requirements). |
| A3 | `disabled` for automatic mode | Prevents hover/focus response in automatic mode. Ships with automatic trigger integration additive phase. |
| A4 | WCAG 1.4.13 pointer bridge | Pointer can move from trigger to tooltip bubble without closing. Managed by `HoverController`. Blocked on `HoverController` extraction. |
| A5 | `no-tip` property | Shown in Figma (Orientation section, "No tip") but not supported by React Spectrum. Deferred. Create a follow-on ticket when React adds support. |
@@ -242,6 +247,8 @@ Neither controller is available yet. The automatic trigger integration additive
| A11 | `labeling` attribute — `aria-labelledby` wiring | The base ARIA wiring (A4 in must-ship) always uses `ariaDescribedByElements`. When `labeling` is set, the SWC layer uses `ariaLabelledByElements` instead — for icon-only triggers where the tooltip text is the sole accessible name and adding `accessible-label` directly to the trigger is not possible. `role="tooltip"` is retained. Works in automatic and manual modes. |
| A12 | Inner interactive element selector expansion | Initial implementation uses `querySelector('button')` as the convention for resolving the inner interactive element within a trigger's shadow root. Expand to support additional interactive elements (``, ``, `