diff --git a/.changeset/four-clowns-read.md b/.changeset/four-clowns-read.md
new file mode 100644
index 00000000000..c3c09bb9593
--- /dev/null
+++ b/.changeset/four-clowns-read.md
@@ -0,0 +1,6 @@
+---
+'@clerk/clerk-js': patch
+'@clerk/types': patch
+---
+
+Introduce `` primitive
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index cbafeb4b68a..346b4b026ee 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -5,7 +5,7 @@
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "110KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "52KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "104KB" },
- { "path": "./dist/vendors*.js", "maxSize": "39KB" },
+ { "path": "./dist/vendors*.js", "maxSize": "39.5KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "38KB" },
{ "path": "./dist/createorganization*.js", "maxSize": "5KB" },
{ "path": "./dist/impersonationfab*.js", "maxSize": "5KB" },
diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
index 4575bb3a936..f5d56d3ae25 100644
--- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
+++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
@@ -422,6 +422,10 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'qrCodeRow',
'qrCodeContainer',
+ 'tooltip',
+ 'tooltipContent',
+ 'tooltipText',
+
'badge',
'notificationBadge',
'buttonArrowIcon',
diff --git a/packages/clerk-js/src/ui/elements/Tooltip.tsx b/packages/clerk-js/src/ui/elements/Tooltip.tsx
new file mode 100644
index 00000000000..2d4dd5bdc2f
--- /dev/null
+++ b/packages/clerk-js/src/ui/elements/Tooltip.tsx
@@ -0,0 +1,220 @@
+import type { Placement } from '@floating-ui/react';
+import {
+ autoUpdate,
+ flip,
+ FloatingPortal,
+ offset,
+ shift,
+ useDismiss,
+ useFloating,
+ useFocus,
+ useHover,
+ useInteractions,
+ useMergeRefs,
+ useRole,
+ useTransitionStyles,
+} from '@floating-ui/react';
+import * as React from 'react';
+
+import { Box, descriptors, type LocalizationKey, Span, Text, useAppearance } from '../customizables';
+import { usePrefersReducedMotion } from '../hooks';
+
+interface TooltipOptions {
+ initialOpen?: boolean;
+ placement?: Placement;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}
+
+export function useTooltip({
+ initialOpen = false,
+ placement = 'top',
+ open: controlledOpen,
+ onOpenChange: setControlledOpen,
+}: TooltipOptions = {}) {
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen);
+
+ const open = controlledOpen ?? uncontrolledOpen;
+ const setOpen = setControlledOpen ?? setUncontrolledOpen;
+
+ const prefersReducedMotion = usePrefersReducedMotion();
+ const { animations: layoutAnimations } = useAppearance().parsedLayout;
+ const isMotionSafe = !prefersReducedMotion && layoutAnimations === true;
+
+ const data = useFloating({
+ placement,
+ open,
+ onOpenChange: setOpen,
+ whileElementsMounted: autoUpdate,
+ middleware: [
+ offset(6),
+ flip({
+ crossAxis: placement.includes('-'),
+ fallbackAxisSideDirection: 'start',
+ padding: 6,
+ }),
+ shift({ padding: 6 }),
+ ],
+ });
+
+ const context = data.context;
+
+ const hover = useHover(context, {
+ move: false,
+ enabled: controlledOpen == null,
+ });
+ const focus = useFocus(context, {
+ enabled: controlledOpen == null,
+ });
+ const dismiss = useDismiss(context);
+ const role = useRole(context, { role: 'tooltip' });
+
+ const { isMounted, styles: transitionStyles } = useTransitionStyles(context, {
+ duration: isMotionSafe ? 200 : 0,
+ initial: ({ side }) => {
+ return {
+ opacity: 0,
+ transform: side === 'top' ? 'translateY(4px)' : 'translateY(-4px)',
+ };
+ },
+ open: {
+ opacity: 1,
+ transform: 'translate(0)',
+ },
+ close: ({ side }) => ({
+ opacity: 0,
+ transform: side === 'top' ? 'translateY(4px)' : 'translateY(-4px)',
+ }),
+ });
+
+ const interactions = useInteractions([hover, focus, dismiss, role]);
+
+ return React.useMemo(
+ () => ({
+ open,
+ setOpen,
+ isMounted,
+ ...interactions,
+ ...data,
+ transitionStyles,
+ }),
+ [open, setOpen, interactions, data, isMounted, transitionStyles],
+ );
+}
+
+type ContextType = ReturnType | null;
+
+const TooltipContext = React.createContext(null);
+
+export const useTooltipContext = () => {
+ const context = React.useContext(TooltipContext);
+
+ if (context == null) {
+ throw new Error('Tooltip components must be wrapped in ');
+ }
+
+ return context;
+};
+
+function Root({ children, ...options }: { children: React.ReactNode } & TooltipOptions) {
+ // This can accept any props as options, e.g. `placement`,
+ // or other positioning options.
+ const tooltip = useTooltip(options);
+ return {children};
+}
+
+const Trigger = React.forwardRef>(function TooltipTrigger(
+ { children, ...props },
+ propRef,
+) {
+ const context = useTooltipContext();
+ const childrenRef = (children as any).ref;
+ const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]);
+
+ if (!React.isValidElement(children)) {
+ return null;
+ }
+
+ // If the child is disabled, wrap it in a span to handle hover events
+ if (children.props.isDisabled || children.props.disabled) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return React.cloneElement(
+ children,
+ context.getReferenceProps({
+ ref,
+ ...props,
+ ...children.props,
+ 'data-state': context.open ? 'open' : 'closed',
+ }),
+ );
+});
+
+const Content = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLProps & {
+ text: string | LocalizationKey;
+ }
+>(function TooltipContent({ style, text, ...props }, propRef) {
+ const context = useTooltipContext();
+ const ref = useMergeRefs([context.refs.setFloating, propRef]);
+
+ if (!context.isMounted) return null;
+
+ return (
+
+
+ ({
+ paddingBlock: t.space.$1,
+ paddingInline: t.space.$1x5,
+ borderRadius: t.radii.$md,
+ backgroundColor: t.colors.$primary500,
+ maxWidth: t.sizes.$60,
+ })}
+ >
+
+
+
+
+ );
+});
+
+export const Tooltip = {
+ Root,
+ Trigger,
+ Content,
+};
diff --git a/packages/clerk-js/src/ui/elements/index.ts b/packages/clerk-js/src/ui/elements/index.ts
index aa2272771e5..d57f5bf1549 100644
--- a/packages/clerk-js/src/ui/elements/index.ts
+++ b/packages/clerk-js/src/ui/elements/index.ts
@@ -56,6 +56,7 @@ export * from './TagInput';
export * from './ThreeDotsMenu';
export * from './TileButton';
export * from './TimerButton';
+export * from './Tooltip';
export * from './UserAvatar';
export * from './UserPreview';
export * from './VerificationCodeCard';
diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts
index 292fa51a751..e1d81e7dc2f 100644
--- a/packages/types/src/appearance.ts
+++ b/packages/types/src/appearance.ts
@@ -541,6 +541,10 @@ export type ElementsConfig = {
impersonationFabTitle: WithOptions;
impersonationFabActionLink: WithOptions;
+ tooltip: WithOptions;
+ tooltipContent: WithOptions;
+ tooltipText: WithOptions;
+
invitationsSentIconBox: WithOptions;
invitationsSentIcon: WithOptions;