A short label that appears when a control is hovered or focused.
View codeHide code
import { Tooltip, TooltipContent, TooltipTrigger } from '@syncclouds/ui'
const TOOLS = [
{ label: 'Bold', shortcut: 'Ctrl B', glyph: 'B' },
{ label: 'Italic', shortcut: 'Ctrl I', glyph: 'I' },
{ label: 'Link', shortcut: 'Ctrl K', glyph: '↗' }
]
/* Icon-only triggers keep their own aria-label; the tooltip adds the
shortcut as a description. */
export function TooltipDemo() {
return (
<div className="flex gap-2">
{TOOLS.map(tool => (
<Tooltip key={tool.label}>
<TooltipTrigger aria-label={tool.label}>{tool.glyph}</TooltipTrigger>
<TooltipContent>
{tool.label}, {tool.shortcut}
</TooltipContent>
</Tooltip>
))}
</div>
)
}Installation
pnpm add @syncclouds/uinpm install @syncclouds/uiyarn add @syncclouds/uibun add @syncclouds/uipnpm dlx shadcn@latest add https://ui.syncclouds.com/r/tooltip.jsonnpx shadcn@latest add https://ui.syncclouds.com/r/tooltip.jsonyarn dlx shadcn@latest add https://ui.syncclouds.com/r/tooltip.jsonbunx --bun shadcn@latest add https://ui.syncclouds.com/r/tooltip.jsonThe CLI copies 4 files into your ui folder and installs its dependencies.
1. Install the dependencies
pnpm add @floating-ui/domnpm install @floating-ui/domyarn add @floating-ui/dombun add @floating-ui/dom2. Copy these files into one folder
tooltip.tsxShowHide
'use client'
import {
createContext,
use,
useEffect,
useId,
useRef,
useState,
type ComponentPropsWithRef,
type RefObject
} from 'react'
import type { Placement } from '@floating-ui/dom'
import { Slot } from './slot'
import { useComposedRefs } from './sui-refs'
import { FLOATING_STYLE, useFloating } from './sui-floating'
interface TooltipContextValue {
open: boolean
show: (delay: number) => void
hide: (delay: number) => void
triggerRef: RefObject<HTMLElement | null>
contentId: string
delay: number
}
const TooltipContext = createContext<TooltipContextValue | null>(null)
const useTooltip = (part: string) => {
const context = use(TooltipContext)
if (!context) throw new Error(`<${part}> must be used inside <Tooltip>`)
return context
}
export interface TooltipProps {
/** Milliseconds a pointer rests on the trigger before the tooltip shows. */
delay?: number
children?: React.ReactNode
}
/**
* A tooltip (APG Tooltip). Shows on hover after a short delay, straight
* away on keyboard focus, and after a long press on touch. Esc dismisses
* it. The tooltip can be hovered without closing. It describes the
* trigger, so keep it short, and never put interactive content inside.
*/
export function Tooltip({ delay = 400, children }: TooltipProps) {
const [open, setOpen] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const triggerRef = useRef<HTMLElement | null>(null)
const id = useId()
useEffect(() => () => clearTimeout(timer.current), [])
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [open])
const schedule = (next: boolean, wait: number) => {
clearTimeout(timer.current)
if (wait <= 0) setOpen(next)
else timer.current = setTimeout(() => setOpen(next), wait)
}
return (
<TooltipContext
value={{
open,
show: wait => schedule(true, wait),
hide: wait => schedule(false, wait),
triggerRef,
contentId: `${id}-tooltip`,
delay
}}
>
{children}
</TooltipContext>
)
}
export function TooltipTrigger({
asChild,
ref,
onPointerEnter,
onPointerLeave,
onPointerDown,
onPointerUp,
onPointerCancel,
onFocus,
onBlur,
...props
}: ComponentPropsWithRef<'button'> & { asChild?: boolean }) {
const { open, show, hide, triggerRef, contentId, delay } =
useTooltip('TooltipTrigger')
const composedRef = useComposedRefs(ref, triggerRef)
const Comp = asChild ? Slot : 'button'
return (
<Comp
type={asChild ? undefined : 'button'}
{...props}
ref={composedRef}
aria-describedby={open ? contentId : undefined}
data-scope="tooltip"
data-part="trigger"
data-state={open ? 'open' : 'closed'}
onPointerEnter={(event: React.PointerEvent<HTMLButtonElement>) => {
onPointerEnter?.(event)
if (event.pointerType === 'mouse') show(delay)
}}
onPointerLeave={(event: React.PointerEvent<HTMLButtonElement>) => {
onPointerLeave?.(event)
if (event.pointerType === 'mouse') hide(120)
}}
onPointerDown={(event: React.PointerEvent<HTMLButtonElement>) => {
onPointerDown?.(event)
if (event.pointerType === 'touch') show(500)
else hide(0)
}}
onPointerUp={(event: React.PointerEvent<HTMLButtonElement>) => {
onPointerUp?.(event)
if (event.pointerType === 'touch') hide(open ? 1500 : 0)
}}
onPointerCancel={(event: React.PointerEvent<HTMLButtonElement>) => {
onPointerCancel?.(event)
hide(0)
}}
onFocus={(event: React.FocusEvent<HTMLButtonElement>) => {
onFocus?.(event)
let keyboard = true
try {
keyboard = event.currentTarget.matches(':focus-visible')
} catch {
/* selector unsupported */
}
if (keyboard) show(0)
}}
onBlur={(event: React.FocusEvent<HTMLButtonElement>) => {
onBlur?.(event)
hide(0)
}}
/>
)
}
export interface TooltipContentProps extends ComponentPropsWithRef<'div'> {
placement?: Placement
}
export function TooltipContent(props: TooltipContentProps) {
const { open } = useTooltip('TooltipContent')
return open ? <OpenTooltipContent {...props} /> : null
}
function OpenTooltipContent({
placement = 'top',
style,
ref,
onPointerEnter,
onPointerLeave,
...props
}: TooltipContentProps) {
const { show, hide, triggerRef, contentId } = useTooltip('TooltipContent')
const contentRef = useRef<HTMLDivElement | null>(null)
const composedRef = useComposedRefs(ref, contentRef)
useFloating(triggerRef, contentRef, true, placement)
return (
<div
{...props}
ref={composedRef}
id={contentId}
role="tooltip"
popover="manual"
style={{ ...FLOATING_STYLE, ...style }}
data-scope="tooltip"
data-part="content"
data-state="open"
onPointerEnter={event => {
onPointerEnter?.(event)
show(0)
}}
onPointerLeave={event => {
onPointerLeave?.(event)
hide(120)
}}
/>
)
}
slot.tsxShowHide
import {
Children,
cloneElement,
isValidElement,
type CSSProperties,
type ReactElement,
type ReactNode,
type Ref
} from 'react'
import { composeRefs } from './sui-refs'
type AnyProps = Record<string, unknown>
/**
* Renders its only child with the slot's props merged in, so any component
* can take over the element (`asChild`). Event handlers run child first,
* then the slot's, unless the child's handler called preventDefault.
* Class names join, styles merge, refs compose. No hooks, so it works in
* server components too.
*/
export function Slot({
children,
...slotProps
}: AnyProps & { children?: ReactNode; ref?: Ref<unknown> }) {
const child = Children.only(children)
if (!isValidElement(child)) return null
const element = child as ReactElement<AnyProps>
const childProps = element.props
const merged: AnyProps = { ...slotProps, ...childProps }
for (const key of Object.keys(slotProps)) {
const slotValue = slotProps[key]
const childValue = childProps[key]
if (/^on[A-Z]/.test(key) && typeof slotValue === 'function') {
merged[key] =
typeof childValue === 'function'
? (...args: unknown[]) => {
childValue(...args)
const event = args[0] as { defaultPrevented?: boolean } | undefined
if (!event?.defaultPrevented) slotValue(...args)
}
: slotValue
} else if (key === 'className') {
merged.className = [slotValue, childValue].filter(Boolean).join(' ')
} else if (key === 'style') {
merged.style = {
...(slotValue as CSSProperties),
...(childValue as CSSProperties)
}
}
}
/* Only touch the ref when there is one: a server component rendering
<Slot><Link /></Slot> must not hand a function ref to a client child. */
const slotRef = slotProps.ref as Ref<unknown> | undefined
const childRef = childProps.ref as Ref<unknown> | undefined
if (slotRef && childRef) merged.ref = composeRefs(slotRef, childRef)
else if (slotRef || childRef) merged.ref = slotRef ?? childRef
else delete merged.ref
return cloneElement(element, merged)
}
sui-refs.tsShowHide
import { useCallback, type Ref, type RefCallback } from 'react'
const setRef = <T,>(ref: Ref<T> | undefined, value: T | null) => {
if (typeof ref === 'function') ref(value)
else if (ref) ref.current = value
}
/** One callback ref that feeds several refs. */
export const composeRefs =
<T,>(...refs: (Ref<T> | undefined)[]): RefCallback<T> =>
node => {
for (const ref of refs) setRef(ref, node)
}
/** A user's ref and a component's own ref, as one stable callback ref. */
export const useComposedRefs = <T,>(
outer: Ref<T> | undefined,
inner: Ref<T> | undefined
) => useCallback((node: T | null) => composeRefs(outer, inner)(node), [outer, inner])
sui-floating.tsShowHide
import { useLayoutEffect, type CSSProperties, type RefObject } from 'react'
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
type Placement
} from '@floating-ui/dom'
/**
* Inline styles every floating part needs: fixed positioning, and no UA
* popover centring. Coordinates are written imperatively below.
*/
export const FLOATING_STYLE: CSSProperties = {
position: 'fixed',
inset: 'auto',
margin: 0,
top: 0,
left: 0
}
/**
* While open, lifts the floating element into the top layer with the
* Popover API (so no overflow or z-index can clip it) and keeps it placed
* next to its anchor. Browsers without the API still get fixed placement.
*/
export function useFloating(
anchor: RefObject<HTMLElement | null>,
floating: RefObject<HTMLElement | null>,
open: boolean,
placement: Placement
) {
useLayoutEffect(() => {
const reference = anchor.current
const element = floating.current
if (!open || !reference || !element) return
try {
element.showPopover?.()
} catch {
/* already open, or no popover attribute */
}
const update = () =>
computePosition(reference, element, {
placement,
strategy: 'fixed',
middleware: [offset(6), flip(), shift({ padding: 8 })]
}).then(({ x, y, placement: final }) => {
element.style.left = `${x}px`
element.style.top = `${y}px`
element.dataset.side = final.split('-')[0]
})
const stop = autoUpdate(reference, element, update)
return () => {
stop()
try {
element.hidePopover?.()
} catch {
/* already hidden */
}
}
}, [anchor, floating, open, placement])
}
3. Import from that folder
The files import each other with relative paths, so keep them together, for example in components/ui.
Usage
import { Tooltip, TooltipContent, TooltipTrigger } from '@syncclouds/ui'<Tooltip>
<TooltipTrigger aria-label="Bold">B</TooltipTrigger>
<TooltipContent>Bold, Ctrl B</TooltipContent>
</Tooltip>Anatomy
Tooltip
├── TooltipTrigger
└── TooltipContentDelay
delay sets how long a pointer rests on the trigger first. Keyboard focus always shows it straight away.
View codeHide code
import { Tooltip, TooltipContent, TooltipTrigger } from '@syncclouds/ui'
export function TooltipDelay() {
return (
<div className="flex gap-3">
<Tooltip delay={0}>
<TooltipTrigger>No delay</TooltipTrigger>
<TooltipContent>Shows at once</TooltipContent>
</Tooltip>
<Tooltip delay={1000}>
<TooltipTrigger>One second</TooltipTrigger>
<TooltipContent>Shows after a second</TooltipContent>
</Tooltip>
</div>
)
}Placement
View codeHide code
import { Tooltip, TooltipContent, TooltipTrigger } from '@syncclouds/ui'
const PLACEMENTS = ['top', 'right', 'bottom', 'left'] as const
export function TooltipPlacement() {
return (
<div className="flex flex-wrap justify-center gap-3">
{PLACEMENTS.map(placement => (
<Tooltip key={placement} delay={0}>
<TooltipTrigger>{placement}</TooltipTrigger>
<TooltipContent placement={placement}>Placed {placement}</TooltipContent>
</Tooltip>
))}
</div>
)
}Without the theme
View codeHide code
import { Tooltip, TooltipContent, TooltipTrigger } from '@syncclouds/ui'
export function TooltipUnstyled() {
return (
<Tooltip delay={200}>
<TooltipTrigger className="rounded-md border border-zinc-300 px-3 py-1.5 text-sm font-medium text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:border-zinc-600 dark:text-zinc-100">
Share
</TooltipTrigger>
<TooltipContent className="rounded-md bg-indigo-700 px-2 py-1 text-xs font-medium text-white dark:bg-indigo-300 dark:text-indigo-950">
Anyone with the link can view
</TooltipContent>
</Tooltip>
)
}Accessibility
Follows the WAI-ARIA Tooltip pattern.
| Keys | What they do |
|---|---|
| Tab | Focusing the trigger shows the tooltip straight away. Moving on hides it. |
| Escape | Hides the tooltip. |
- The pointer can move onto the tooltip without it closing.
- On touch, a long press shows it.
- It describes the trigger, so keep it short and never put links or buttons inside. An icon-only trigger still needs its own aria-label.
API reference
Every part also accepts the props of the element it renders, including className and ref.
Tooltiprenders no element
Holds the open state and timing. Renders no element of its own.
| Prop | Type | Default | Description |
|---|---|---|---|
delay | number | 400 | Milliseconds a pointer rests on the trigger before it shows. |
TooltipTriggerrenders <button>
The control it describes. While open, aria-describedby points at the tooltip.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Use your own element. |
| Data attribute | Values |
|---|---|
data-state | "open" | "closed" |
TooltipContentrenders <div>
The text. Rendered only while open, in the top layer, next to the trigger.
| Prop | Type | Default | Description |
|---|---|---|---|
placement | Placement | "top" | Where it shows. It flips and shifts to stay on screen. |
| Data attribute | Values |
|---|---|
data-state | "open" |
data-side | "top" | "right" | "bottom" | "left" |