diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts
index 6bb6ccf10b..d712d5cb2b 100644
--- a/libs/components/src/icon-button/icon-button.component.ts
+++ b/libs/components/src/icon-button/icon-button.component.ts
@@ -130,7 +130,11 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
.concat(sizes[this.size()])
.concat(
this.showDisabledStyles() || this.disabled()
- ? ["aria-disabled:tw-opacity-60", "aria-disabled:hover:!tw-bg-transparent"]
+ ? [
+ "aria-disabled:tw-opacity-60",
+ "aria-disabled:hover:!tw-bg-transparent",
+ "tw-cursor-default",
+ ]
: [],
);
}
diff --git a/libs/components/src/popover/popover.stories.ts b/libs/components/src/popover/popover.stories.ts
index 6d2cf77e47..596381d077 100644
--- a/libs/components/src/popover/popover.stories.ts
+++ b/libs/components/src/popover/popover.stories.ts
@@ -23,6 +23,7 @@ export default {
useFactory: () => {
return new I18nMockService({
close: "Close",
+ loading: "Loading",
});
},
},
diff --git a/libs/components/src/tooltip/index.ts b/libs/components/src/tooltip/index.ts
new file mode 100644
index 0000000000..28c35fd6ee
--- /dev/null
+++ b/libs/components/src/tooltip/index.ts
@@ -0,0 +1 @@
+export * from "./tooltip.directive";
diff --git a/libs/components/src/tooltip/tooltip-positions.ts b/libs/components/src/tooltip/tooltip-positions.ts
new file mode 100644
index 0000000000..6396bb6632
--- /dev/null
+++ b/libs/components/src/tooltip/tooltip-positions.ts
@@ -0,0 +1,61 @@
+import { ConnectedPosition } from "@angular/cdk/overlay";
+
+const ORIGIN_OFFSET_PX = 10;
+
+export type TooltipPositionIdentifier =
+ | "right-center"
+ | "left-center"
+ | "below-center"
+ | "above-center";
+
+export interface TooltipPosition extends ConnectedPosition {
+ id: TooltipPositionIdentifier;
+}
+
+export const tooltipPositions: TooltipPosition[] = [
+ /**
+ * The order of these positions matters. The Tooltip component will use
+ * the first position that fits within the viewport.
+ */
+
+ // Tooltip opens to right of trigger
+ {
+ id: "right-center",
+ offsetX: ORIGIN_OFFSET_PX,
+ originX: "end",
+ originY: "center",
+ overlayX: "start",
+ overlayY: "center",
+ panelClass: ["bit-tooltip-right-center"],
+ },
+ // ... to left of trigger
+ {
+ id: "left-center",
+ offsetX: -ORIGIN_OFFSET_PX,
+ originX: "start",
+ originY: "center",
+ overlayX: "end",
+ overlayY: "center",
+ panelClass: ["bit-tooltip-left-center"],
+ },
+ // ... below trigger
+ {
+ id: "below-center",
+ offsetY: ORIGIN_OFFSET_PX,
+ originX: "center",
+ originY: "bottom",
+ overlayX: "center",
+ overlayY: "top",
+ panelClass: ["bit-tooltip-below-center"],
+ },
+ // ... above trigger
+ {
+ id: "above-center",
+ offsetY: -ORIGIN_OFFSET_PX,
+ originX: "center",
+ originY: "top",
+ overlayX: "center",
+ overlayY: "bottom",
+ panelClass: ["bit-tooltip-above-center"],
+ },
+];
diff --git a/libs/components/src/tooltip/tooltip.component.css b/libs/components/src/tooltip/tooltip.component.css
new file mode 100644
index 0000000000..4abb9908f2
--- /dev/null
+++ b/libs/components/src/tooltip/tooltip.component.css
@@ -0,0 +1,132 @@
+:root {
+ --tooltip-shadow: rgb(0 0 0 / 0.1);
+}
+
+.cdk-overlay-pane:has(.bit-tooltip[data-visible="false"]) {
+ pointer-events: none;
+}
+
+.bit-tooltip-container {
+ position: relative;
+ max-width: 12rem;
+ opacity: 0;
+ width: max-content;
+ box-shadow:
+ 0 4px 6px -1px var(--tooltip-shadow),
+ 0 2px 4px -2px var(--tooltip-shadow);
+ border-radius: 0.75rem;
+ transition:
+ transform 100ms ease-in-out,
+ opacity 100ms ease-in-out;
+ transform: scale(0.95);
+ z-index: 1;
+
+ &::before,
+ &::after {
+ content: "";
+ position: absolute;
+ width: 1rem;
+ height: 1rem;
+ z-index: 1;
+ rotate: 45deg;
+ border-radius: 3px;
+ }
+
+ &::before {
+ background: linear-gradient(135deg, transparent 50%, rgb(var(--color-text-main)) 50%);
+ z-index: -1;
+ }
+
+ &::after {
+ background: rgb(var(--color-text-main));
+ z-index: -1;
+ }
+
+ &[data-visible="true"] {
+ opacity: 1;
+ transform: scale(1);
+ z-index: 1000;
+ }
+
+ .bit-tooltip-above-center &,
+ .bit-tooltip-below-center & {
+ &::before,
+ &::after {
+ inset-inline-start: 50%;
+ transform: translateX(-50%);
+ transform-origin: left;
+ }
+ }
+
+ .bit-tooltip-above-center & {
+ &::after {
+ filter: drop-shadow(0 3px 5px var(--tooltip-shadow))
+ drop-shadow(0 1px 3px var(--tooltip-shadow));
+ }
+
+ &::before,
+ &::after {
+ inset-block-end: -0.25rem;
+ }
+ }
+
+ .bit-tooltip-below-center & {
+ &::after {
+ display: none;
+ }
+
+ &::after,
+ &::before {
+ inset-block-start: -0.25rem;
+ rotate: -135deg;
+ }
+ }
+
+ .bit-tooltip-left-center &,
+ .bit-tooltip-right-center & {
+ &::after,
+ &::before {
+ inset-block-start: 50%;
+ transform: translateY(-50%);
+ transform-origin: top;
+ }
+ }
+
+ .bit-tooltip-left-center & {
+ &::after {
+ filter: drop-shadow(-3px 1px 3px var(--tooltip-shadow))
+ drop-shadow(-1px 2px 3px var(--tooltip-shadow));
+ }
+
+ &::after,
+ &::before {
+ inset-inline-end: -0.25rem;
+ rotate: -45deg;
+ }
+ }
+
+ .bit-tooltip-right-center & {
+ &::after {
+ filter: drop-shadow(2px -4px 2px var(--tooltip-shadow))
+ drop-shadow(0 -1px 3px var(--tooltip-shadow));
+ }
+
+ &::after,
+ &::before {
+ inset-inline-start: -0.25rem;
+ rotate: 135deg;
+ }
+ }
+}
+
+.bit-tooltip {
+ width: max-content;
+ max-width: 12rem;
+ background-color: rgb(var(--color-text-main));
+ color: rgb(var(--color-text-contrast));
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.75rem;
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ z-index: 2;
+}
diff --git a/libs/components/src/tooltip/tooltip.component.html b/libs/components/src/tooltip/tooltip.component.html
new file mode 100644
index 0000000000..c75cd5fb0d
--- /dev/null
+++ b/libs/components/src/tooltip/tooltip.component.html
@@ -0,0 +1,10 @@
+
+
diff --git a/libs/components/src/tooltip/tooltip.component.ts b/libs/components/src/tooltip/tooltip.component.ts
new file mode 100644
index 0000000000..6b24050731
--- /dev/null
+++ b/libs/components/src/tooltip/tooltip.component.ts
@@ -0,0 +1,36 @@
+import { CommonModule } from "@angular/common";
+import {
+ Component,
+ ElementRef,
+ inject,
+ InjectionToken,
+ Signal,
+ TemplateRef,
+ viewChild,
+} from "@angular/core";
+
+import { TooltipPosition } from "./tooltip-positions";
+
+type TooltipData = {
+ content: Signal;
+ isVisible: Signal;
+ tooltipPosition: Signal;
+};
+
+export const TOOLTIP_DATA = new InjectionToken("TOOLTIP_DATA");
+
+/**
+ * tooltip component used internally by the tooltip.directive. Not meant to be used explicitly
+ */
+@Component({
+ selector: "bit-tooltip",
+ templateUrl: "./tooltip.component.html",
+ imports: [CommonModule],
+})
+export class TooltipComponent {
+ readonly templateRef = viewChild.required(TemplateRef);
+
+ private elementRef = inject(ElementRef);
+
+ readonly tooltipData = inject(TOOLTIP_DATA);
+}
diff --git a/libs/components/src/tooltip/tooltip.directive.ts b/libs/components/src/tooltip/tooltip.directive.ts
new file mode 100644
index 0000000000..153fecfe7b
--- /dev/null
+++ b/libs/components/src/tooltip/tooltip.directive.ts
@@ -0,0 +1,110 @@
+import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay";
+import { ComponentPortal } from "@angular/cdk/portal";
+import {
+ Directive,
+ ViewContainerRef,
+ inject,
+ OnInit,
+ ElementRef,
+ Injector,
+ input,
+ effect,
+ signal,
+} from "@angular/core";
+
+import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions";
+import { TooltipComponent, TOOLTIP_DATA } from "./tooltip.component";
+
+/**
+ * Directive to add a tooltip to any element. The tooltip content is provided via the `bitTooltip` input.
+ * The position of the tooltip can be set via the `tooltipPosition` input. Default position is "above-center".
+ */
+@Directive({
+ selector: "[bitTooltip]",
+ host: {
+ "(mouseenter)": "showTooltip()",
+ "(mouseleave)": "hideTooltip()",
+ "(focus)": "showTooltip()",
+ "(blur)": "hideTooltip()",
+ },
+})
+export class TooltipDirective implements OnInit {
+ /**
+ * The value of this input is forwarded to the tooltip.component to render
+ */
+ readonly bitTooltip = input.required();
+ /**
+ * The value of this input is forwarded to the tooltip.component to set its position explicitly.
+ * @default "above-center"
+ */
+ readonly tooltipPosition = input("above-center");
+
+ private isVisible = signal(false);
+ private overlayRef: OverlayRef | undefined;
+ private elementRef = inject(ElementRef);
+ private overlay = inject(Overlay);
+ private viewContainerRef = inject(ViewContainerRef);
+ private injector = inject(Injector);
+ private positionStrategy = this.overlay
+ .position()
+ .flexibleConnectedTo(this.elementRef)
+ .withFlexibleDimensions(false)
+ .withPush(true);
+
+ private tooltipPortal = new ComponentPortal(
+ TooltipComponent,
+ this.viewContainerRef,
+ Injector.create({
+ providers: [
+ {
+ provide: TOOLTIP_DATA,
+ useValue: {
+ content: this.bitTooltip,
+ isVisible: this.isVisible,
+ tooltipPosition: this.tooltipPosition,
+ },
+ },
+ ],
+ }),
+ );
+
+ private showTooltip = () => {
+ this.isVisible.set(true);
+ };
+
+ private hideTooltip = () => {
+ this.isVisible.set(false);
+ };
+
+ private computePositions(tooltipPosition: TooltipPositionIdentifier) {
+ const chosenPosition = tooltipPositions.find((position) => position.id === tooltipPosition);
+
+ return chosenPosition ? [chosenPosition, ...tooltipPositions] : tooltipPositions;
+ }
+
+ get defaultPopoverConfig(): OverlayConfig {
+ return {
+ hasBackdrop: false,
+ scrollStrategy: this.overlay.scrollStrategies.reposition(),
+ };
+ }
+
+ ngOnInit() {
+ this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition()));
+
+ this.overlayRef = this.overlay.create({
+ ...this.defaultPopoverConfig,
+ positionStrategy: this.positionStrategy,
+ });
+
+ this.overlayRef.attach(this.tooltipPortal);
+
+ effect(
+ () => {
+ this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition()));
+ this.overlayRef?.updatePosition();
+ },
+ { injector: this.injector },
+ );
+ }
+}
diff --git a/libs/components/src/tooltip/tooltip.mdx b/libs/components/src/tooltip/tooltip.mdx
new file mode 100644
index 0000000000..4b6f10d97f
--- /dev/null
+++ b/libs/components/src/tooltip/tooltip.mdx
@@ -0,0 +1,31 @@
+import { Meta, Canvas, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs";
+
+import * as stories from "./tooltip.stories";
+
+
+
+```ts
+import { TooltipDirective } from "@bitwarden/components";
+```
+
+
+
+
+NOTE: The `TooltipComponent` can't be used on its own. It must be applied via the `TooltipDirective`
+
+
+
+
+## Stories
+
+### All available positions
+
+
+
+### Used with a long content
+
+
+
+### On disabled element
+
+
diff --git a/libs/components/src/tooltip/tooltip.spec.ts b/libs/components/src/tooltip/tooltip.spec.ts
new file mode 100644
index 0000000000..57e05e4f65
--- /dev/null
+++ b/libs/components/src/tooltip/tooltip.spec.ts
@@ -0,0 +1,103 @@
+import {
+ ConnectedOverlayPositionChange,
+ ConnectionPositionPair,
+ OverlayConfig,
+ Overlay,
+} from "@angular/cdk/overlay";
+import { ComponentPortal } from "@angular/cdk/portal";
+import { Component } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { Observable, Subject } from "rxjs";
+
+import { TooltipDirective } from "./tooltip.directive";
+
+@Component({
+ standalone: true,
+ imports: [TooltipDirective],
+ template: ` `,
+})
+class TooltipHostComponent {
+ tooltipText = "Hello Tooltip";
+}
+
+/** Minimal strategy shape the directive expects */
+interface StrategyLike {
+ withFlexibleDimensions: (flex: boolean) => StrategyLike;
+ withPush: (push: boolean) => StrategyLike;
+ withPositions: (positions: ReadonlyArray) => StrategyLike;
+ readonly positionChanges: Observable;
+}
+
+/** Minimal Overlay service shape */
+interface OverlayLike {
+ position: () => { flexibleConnectedTo: (_: unknown) => StrategyLike };
+ create: (_: OverlayConfig) => OverlayRefStub;
+ scrollStrategies: { reposition: () => unknown };
+}
+
+interface OverlayRefStub {
+ attach: (portal: ComponentPortal) => unknown;
+ updatePosition: () => void;
+}
+
+describe("TooltipDirective (visibility only)", () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(() => {
+ const positionChanges$ = new Subject();
+
+ const strategy: StrategyLike = {
+ withFlexibleDimensions: jest.fn(() => strategy),
+ withPush: jest.fn(() => strategy),
+ withPositions: jest.fn(() => strategy),
+ get positionChanges() {
+ return positionChanges$.asObservable();
+ },
+ };
+
+ const overlayRefStub: OverlayRefStub = {
+ attach: jest.fn(() => ({})),
+ updatePosition: jest.fn(),
+ };
+
+ const overlayMock: OverlayLike = {
+ position: () => ({ flexibleConnectedTo: () => strategy }),
+ create: (_: OverlayConfig) => overlayRefStub,
+ scrollStrategies: { reposition: () => ({}) },
+ };
+
+ TestBed.configureTestingModule({
+ imports: [TooltipHostComponent],
+ providers: [{ provide: Overlay, useValue: overlayMock as unknown as Overlay }],
+ });
+
+ fixture = TestBed.createComponent(TooltipHostComponent);
+ fixture.detectChanges();
+ });
+
+ function getDirective(): TooltipDirective {
+ const hostDE = fixture.debugElement.query(By.directive(TooltipDirective));
+ return hostDE.injector.get(TooltipDirective);
+ }
+
+ it("sets isVisible to true on mouseenter", () => {
+ const button: HTMLButtonElement = fixture.debugElement.query(By.css("button")).nativeElement;
+ const directive = getDirective();
+
+ const isVisible = (directive as unknown as { isVisible: () => boolean }).isVisible;
+
+ button.dispatchEvent(new Event("mouseenter"));
+ expect(isVisible()).toBe(true);
+ });
+
+ it("sets isVisible to true on focus", () => {
+ const button: HTMLButtonElement = fixture.debugElement.query(By.css("button")).nativeElement;
+ const directive = getDirective();
+
+ const isVisible = (directive as unknown as { isVisible: () => boolean }).isVisible;
+
+ button.dispatchEvent(new Event("focus"));
+ expect(isVisible()).toBe(true);
+ });
+});
diff --git a/libs/components/src/tooltip/tooltip.stories.ts b/libs/components/src/tooltip/tooltip.stories.ts
new file mode 100644
index 0000000000..8ea3f52f91
--- /dev/null
+++ b/libs/components/src/tooltip/tooltip.stories.ts
@@ -0,0 +1,153 @@
+import { signal } from "@angular/core";
+import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
+import { getByRole, userEvent } from "@storybook/test";
+
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+
+import { ButtonComponent } from "../button";
+import { BitIconButtonComponent } from "../icon-button";
+import { I18nMockService } from "../utils";
+
+import { TooltipPosition, TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions";
+import { TOOLTIP_DATA, TooltipComponent } from "./tooltip.component";
+import { TooltipDirective } from "./tooltip.directive";
+
+import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet";
+
+export default {
+ title: "Component Library/Tooltip",
+ component: TooltipDirective,
+ decorators: [
+ moduleMetadata({
+ imports: [TooltipDirective, TooltipComponent, BitIconButtonComponent, ButtonComponent],
+ providers: [
+ {
+ provide: I18nService,
+ useFactory: () => {
+ return new I18nMockService({
+ loading: "Loading",
+ });
+ },
+ },
+ {
+ provide: TOOLTIP_DATA,
+ useFactory: () => {
+ // simple fixed demo values for the Default story
+ return {
+ content: signal("This is a tooltip"),
+ isVisible: signal(true),
+ tooltipPosition: signal("above-center"),
+ };
+ },
+ },
+ ],
+ }),
+ ],
+ parameters: {
+ design: {
+ type: "figma",
+ url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?m=auto&node-id=30558-13730&t=4k23PtzCwqDekAZW-1",
+ },
+ },
+ argTypes: {
+ bitTooltip: {
+ control: "text",
+ description: "Text content of the tooltip",
+ },
+ tooltipPosition: {
+ control: "select",
+ options: tooltipPositions.map((position: TooltipPosition) => position.id),
+ description: "Position of the tooltip relative to the element",
+ table: {
+ type: {
+ summary: tooltipPositions.map((position: TooltipPosition) => position.id).join(" | "),
+ },
+ defaultValue: { summary: "above-center" },
+ },
+ },
+ },
+} as Meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ bitTooltip: "This is a tooltip",
+ tooltipPosition: "above-center",
+ },
+ render: (args) => ({
+ props: args,
+ template: `
+
+
+
+ `,
+ }),
+ play: async (context) => {
+ const canvasEl = context.canvasElement;
+ const button = getByRole(canvasEl, "button");
+
+ await userEvent.hover(button);
+ },
+};
+
+export const AllPositions: Story = {
+ render: () => ({
+ template: `
+
+
+
+
+
+
+ `,
+ }),
+};
+
+export const LongContent: Story = {
+ render: () => ({
+ template: `
+
+
+
+ `,
+ }),
+};
+
+export const OnDisabledButton: Story = {
+ render: () => ({
+ template: `
+
+
+
+ `,
+ }),
+};
diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css
index ec29bc522e..1e0a6f438f 100644
--- a/libs/components/src/tw-theme.css
+++ b/libs/components/src/tw-theme.css
@@ -5,6 +5,7 @@
@import "./popover/popover.component.css";
@import "./toast/toast.tokens.css";
@import "./toast/toastr.css";
+@import "./tooltip/tooltip.component.css";
@import "./search/search.component.css";
@tailwind base;