diff --git a/.changeset/swingset-tooltip-primitive.md b/.changeset/swingset-tooltip-primitive.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/swingset-tooltip-primitive.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/swingset/src/app/layout.tsx b/packages/swingset/src/app/layout.tsx
index 10b6cd9d4c4..09ae389ad1c 100644
--- a/packages/swingset/src/app/layout.tsx
+++ b/packages/swingset/src/app/layout.tsx
@@ -22,13 +22,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
suppressHydrationWarning
className={cn('font-sans', geist.variable)}
>
- {process.env.NODE_ENV === 'development' && (
-
- )}
+
+ {process.env.NODE_ENV === 'development' && (
+
+ )}
+
{children}
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 083cd38c3ad..94776a2d15e 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic';
const docModules: Record = {
button: dynamic(() => import('../stories/button.mdx')),
+ tooltip: dynamic(() => import('../stories/tooltip.mdx')),
};
interface DocsViewerProps {
diff --git a/packages/swingset/src/components/StoryCanvas.tsx b/packages/swingset/src/components/StoryCanvas.tsx
index 966ae4997bd..b54b42e8976 100644
--- a/packages/swingset/src/components/StoryCanvas.tsx
+++ b/packages/swingset/src/components/StoryCanvas.tsx
@@ -3,7 +3,7 @@
import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider';
import type { MosaicVariables } from '@clerk/ui/mosaic/variables';
import type React from 'react';
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { generateKnobs, initKnobValues } from '@/lib/generateKnobs';
@@ -21,18 +21,20 @@ interface StoryCanvasProps {
export function StoryCanvas({ componentSlug, storySlug }: StoryCanvasProps) {
const found = findStory(componentSlug, storySlug);
- const [knobs, setKnobs] = useState(() => (found ? generateKnobs(found.mod.meta) : {}));
+ // Derive knobs from the slug primitives — `found` is a fresh object every render, so depending
+ // on it (in the effect below) would reset state on every render and loop until React bails out.
+ const knobs = useMemo(() => {
+ const entry = findStory(componentSlug, storySlug);
+ return entry ? generateKnobs(entry.mod.meta) : {};
+ }, [componentSlug, storySlug]);
+
const [knobValues, setKnobValues] = useState(() => initKnobValues(knobs));
const [variables, setVariables] = useState({});
+ // Reset knob values when the active story (and therefore its knobs) changes.
useEffect(() => {
- if (!found) {
- return;
- }
- const nextKnobs = generateKnobs(found.mod.meta);
- setKnobs(nextKnobs);
- setKnobValues(initKnobValues(nextKnobs));
- }, [found, componentSlug, storySlug]);
+ setKnobValues(initKnobValues(knobs));
+ }, [knobs]);
if (!found) {
return Story not found.
;
diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx
index de8b4f42b47..9bad08ac0f7 100644
--- a/packages/swingset/src/components/app-sidebar.tsx
+++ b/packages/swingset/src/components/app-sidebar.tsx
@@ -23,9 +23,75 @@ import { toSlug } from '@/lib/slug';
const groups = getSidebarGroups();
-export function AppSidebar({ ...props }: React.ComponentProps) {
+type ComponentGroupProps = {
+ title: string;
+ componentSlug: string;
+ names: string[];
+};
+
+// One controlled Collapsible per component. The group opens when its route becomes
+// active, but stays user-toggleable otherwise. It must be controlled (not `defaultOpen`)
+// because the instance persists across navigation: `defaultOpen` is only read on init,
+// so a route-derived value mutating later trips Base UI's uncontrolled-state warning.
+function ComponentGroup({ title, componentSlug, names }: ComponentGroupProps) {
const pathname = usePathname();
+ const docsHref = `/components/${componentSlug}`;
+ const isActive = pathname.startsWith(docsHref);
+ const [open, setOpen] = React.useState(isActive);
+
+ React.useEffect(() => {
+ if (isActive) {
+ setOpen(true);
+ }
+ }, [isActive]);
+ return (
+
+
+ }
+ >
+ {title}
+
+
+
+
+
+
+ }
+ >
+ Overview
+
+
+ {names.map(name => {
+ const href = `/components/${componentSlug}/${toSlug(name)}`;
+ return (
+
+ }
+ >
+ {name}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+export function AppSidebar({ ...props }: React.ComponentProps) {
return (
@@ -33,55 +99,14 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
{groups.flatMap(({ stories }) =>
- stories.map(({ mod, componentSlug, names }) => {
- const docsHref = `/components/${componentSlug}`;
- const isOpen = pathname.startsWith(docsHref);
-
- return (
-
-
- }
- >
- {mod.meta.title}
-
-
-
-
-
-
- }
- >
- Overview
-
-
- {names.map(name => {
- const href = `/components/${componentSlug}/${toSlug(name)}`;
- return (
-
- }
- >
- {name}
-
-
- );
- })}
-
-
-
-
-
- );
- }),
+ stories.map(({ mod, componentSlug, names }) => (
+
+ )),
)}
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index 457ef5be4e1..1553e07d988 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -1,11 +1,13 @@
// Import stories explicitly to control order and avoid type casting through unknown.
import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button.stories';
+import { meta as tooltipMeta, Options as TooltipOptions } from '../stories/tooltip.stories';
import { toSlug } from './slug';
import type { StoryModule } from './types';
const buttonModule: StoryModule = { meta: buttonMeta, Primary, Sizes, Disabled };
+const tooltipModule: StoryModule = { meta: tooltipMeta, Options: TooltipOptions };
-export const registry: StoryModule[] = [buttonModule];
+export const registry: StoryModule[] = [buttonModule, tooltipModule];
export interface RegistryEntry {
mod: StoryModule;
diff --git a/packages/swingset/src/stories/tooltip.mdx b/packages/swingset/src/stories/tooltip.mdx
new file mode 100644
index 00000000000..beeedc12c9a
--- /dev/null
+++ b/packages/swingset/src/stories/tooltip.mdx
@@ -0,0 +1,33 @@
+import * as TooltipStories from './tooltip.stories';
+
+# Tooltip
+
+The `Tooltip` component reveals a small label when its trigger is hovered or focused. It wraps any trigger element and positions the bubble with the `side` variant. Focus and blur bubble up from the trigger, so the tooltip is keyboard-accessible without extra wiring.
+
+## Options
+
+
+
+## Usage
+
+```tsx
+import { Tooltip } from '@clerk/ui/mosaic/components/tooltip';
+import { Button } from '@clerk/ui/mosaic/components/button';
+
+
+
+;
+```
+
+## Props
+
+
diff --git a/packages/swingset/src/stories/tooltip.stories.tsx b/packages/swingset/src/stories/tooltip.stories.tsx
new file mode 100644
index 00000000000..7ff8d672f9b
--- /dev/null
+++ b/packages/swingset/src/stories/tooltip.stories.tsx
@@ -0,0 +1,53 @@
+/** @jsxImportSource @emotion/react */
+import { Button } from '@clerk/ui/mosaic/components/button';
+import type { TooltipProps } from '@clerk/ui/mosaic/components/tooltip';
+import { Tooltip, tooltipStyles } from '@clerk/ui/mosaic/components/tooltip';
+
+import type { StoryMeta } from '@/lib/types';
+
+export const meta: StoryMeta = {
+ group: 'Components',
+ title: 'Tooltip',
+ styles: tooltipStyles,
+};
+
+// Story functions accept Record (knob values) and cast to TooltipProps.
+// The cast is unavoidable: knobs are dynamically typed; Tooltip has a strict prop interface.
+function knobsAsProps(props: Record) {
+ return props as unknown as TooltipProps;
+}
+
+export function Options(props: Record) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/mosaic/components/tooltip.tsx b/packages/ui/src/mosaic/components/tooltip.tsx
new file mode 100644
index 00000000000..a4028a33b84
--- /dev/null
+++ b/packages/ui/src/mosaic/components/tooltip.tsx
@@ -0,0 +1,96 @@
+/** @jsxImportSource @emotion/react */
+import React from 'react';
+
+import { cva, type VariantProps } from '../cva';
+import { useMosaicTheme } from '../MosaicProvider';
+
+export const tooltipStyles = cva(theme => ({
+ base: {
+ position: 'absolute',
+ zIndex: 50,
+ width: 'max-content',
+ maxWidth: '18rem',
+ paddingInline: theme.spacing(2),
+ paddingBlock: theme.spacing(1),
+ borderRadius: theme.rounded.md,
+ backgroundColor: theme.color.primary,
+ color: theme.color.primaryForeground,
+ fontWeight: 500,
+ ...theme.text('xs'),
+ pointerEvents: 'none',
+ whiteSpace: 'normal',
+ },
+ variants: {
+ side: {
+ top: {
+ bottom: '100%',
+ left: '50%',
+ transform: 'translateX(-50%)',
+ marginBottom: theme.spacing(2),
+ },
+ bottom: {
+ top: '100%',
+ left: '50%',
+ transform: 'translateX(-50%)',
+ marginTop: theme.spacing(2),
+ },
+ left: {
+ right: '100%',
+ top: '50%',
+ transform: 'translateY(-50%)',
+ marginRight: theme.spacing(2),
+ },
+ right: {
+ left: '100%',
+ top: '50%',
+ transform: 'translateY(-50%)',
+ marginLeft: theme.spacing(2),
+ },
+ },
+ },
+ defaultVariants: { side: 'top' },
+}));
+
+export type TooltipProps = Omit, 'content'> &
+ VariantProps & {
+ /** The content shown inside the tooltip bubble. */
+ content: React.ReactNode;
+ };
+
+/**
+ * Wraps a trigger and reveals `content` on hover or keyboard focus.
+ *
+ * The trigger element is passed as `children`; the tooltip is positioned relative
+ * to it via the `side` variant. Focus and blur bubble up from the trigger, so the
+ * tooltip is reachable by keyboard without extra wiring.
+ */
+export const Tooltip = React.forwardRef(function MosaicTooltip(props, ref) {
+ const { side, sx, content, children, ...rest } = props;
+ const theme = useMosaicTheme();
+ const [open, setOpen] = React.useState(false);
+ const tooltipId = React.useId();
+
+ return (
+ setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+ onFocus={() => setOpen(true)}
+ onBlur={() => setOpen(false)}
+ aria-describedby={open ? tooltipId : undefined}
+ {...rest}
+ >
+ {children}
+ {open ? (
+
+ {content}
+
+ ) : null}
+
+ );
+});