diff --git a/web/src/components/ui/tooltip.test.tsx b/web/src/components/ui/tooltip.test.tsx
new file mode 100644
index 0000000..b2e6e80
--- /dev/null
+++ b/web/src/components/ui/tooltip.test.tsx
@@ -0,0 +1,83 @@
+import { describe, it, expect } from "vitest";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
+
+import {
+ Tooltip,
+ TooltipProvider,
+ TooltipRoot,
+ TooltipTrigger,
+ TooltipPositioner,
+ TooltipPopup,
+} from "./tooltip";
+
+describe("Tooltip", () => {
+ it("shows its content in a portal on hover", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ ,
+ );
+
+ const trigger = screen.getByRole("button", { name: "Objects nav" });
+ const body = within(document.body);
+
+ expect(body.queryByText("Objects")).toBeNull();
+
+ await user.hover(trigger);
+
+ expect(await body.findByText("Objects")).toBeVisible();
+ });
+
+ it("shows its content on keyboard focus", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ ,
+ );
+
+ const body = within(document.body);
+
+ await user.tab();
+
+ expect(await body.findByText("Help text")).toBeVisible();
+ });
+
+ it("delegates the trigger element via render (keeps the host tag)", () => {
+ render(
+
+ Go
+ ,
+ );
+
+ const link = screen.getByRole("link", { name: "Go" });
+ expect(link).toHaveAttribute("href", "/objects");
+ });
+
+ it("composes from the raw parts", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ Raw} />
+
+
+ Raw tip
+
+
+
+ ,
+ );
+
+ await user.hover(screen.getByRole("button", { name: "Raw" }));
+
+ const body = within(document.body);
+ expect(await body.findByText("Raw tip")).toBeVisible();
+ });
+});
diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..53b4512
--- /dev/null
+++ b/web/src/components/ui/tooltip.tsx
@@ -0,0 +1,82 @@
+import type { ReactElement, ReactNode } from "react";
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
+
+import { cn } from "@/lib/utils";
+
+type Side = NonNullable;
+
+function TooltipProvider({ ...props }: TooltipPrimitive.Provider.Props) {
+ return ;
+}
+
+function TooltipRoot({ ...props }: TooltipPrimitive.Root.Props) {
+ return ;
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return ;
+}
+
+function TooltipPopup({ className, ...props }: TooltipPrimitive.Popup.Props) {
+ return (
+
+ );
+}
+
+function TooltipPositioner({ className, ...props }: TooltipPrimitive.Positioner.Props) {
+ return (
+
+ );
+}
+
+export type TooltipProps = {
+ /** Text shown in the tooltip popup. */
+ content: ReactNode;
+ /** The element the tooltip is attached to. Rendered as the trigger. */
+ children: ReactElement;
+ /** Which side of the trigger to place the popup. Defaults to `"right"`. */
+ side?: Side;
+ /** Pixel gap between the trigger and the popup. */
+ sideOffset?: number;
+};
+
+/**
+ * Standalone tooltip: wraps its own `Provider`, so it can be dropped in
+ * anywhere without an ancestor provider. `children` is delegated to the
+ * Base UI trigger via `render`, so the underlying element keeps its own
+ * tag/handlers (e.g. a `NavLink` for the collapsed sidebar).
+ */
+function Tooltip({ content, children, side = "right", sideOffset = 6 }: TooltipProps) {
+ return (
+
+
+
+
+
+ {content}
+
+
+
+
+ );
+}
+
+export {
+ Tooltip,
+ TooltipProvider,
+ TooltipRoot,
+ TooltipTrigger,
+ TooltipPositioner,
+ TooltipPopup,
+};
diff --git a/web/src/lib/use-media-query.ts b/web/src/lib/use-media-query.ts
new file mode 100644
index 0000000..c918347
--- /dev/null
+++ b/web/src/lib/use-media-query.ts
@@ -0,0 +1,20 @@
+import { useEffect, useState } from "react";
+
+/** SSR-safe `matchMedia` subscription; `true` while `query` matches. */
+export function useMediaQuery(query: string): boolean {
+ const [matches, setMatches] = useState(() =>
+ typeof window !== "undefined" ? window.matchMedia(query).matches : false,
+ );
+
+ useEffect(() => {
+ const mql = window.matchMedia(query);
+ const onChange = () => setMatches(mql.matches);
+
+ onChange();
+ mql.addEventListener("change", onChange);
+
+ return () => mql.removeEventListener("change", onChange);
+ }, [query]);
+
+ return matches;
+}