Tooltip
Open in v0
Open in Cursor
Open in ChatGPT
Open in Claude
Open in Perplexity
Open in Gemini
Open in Grok
A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.
Add to library
import { buttonVariants } from '#app/ui/button.tsx'
import { Tooltip, TooltipContent, TooltipTrigger } from '#app/ui/tooltip.tsx'
export function TooltipDemo() {
return () => (
<Tooltip>import { buttonVariants } from '#app/ui/button.tsx'
import { Tooltip, TooltipContent, TooltipTrigger } from '#app/ui/tooltip.tsx'
export function TooltipDemo() {
return () => (
<Tooltip>
<TooltipTrigger data-slot="button" mix={buttonVariants({ variant: 'outline' })}>
Hover
</TooltipTrigger>
<TooltipContent>
<p>Add to library</p>
</TooltipContent>
</Tooltip>
)
}Installation
$ bunx --bun shadcn@latest add https://www.shadcn-remix.com/r/tooltip.json$ npx shadcn@latest add https://www.shadcn-remix.com/r/tooltip.json$ pnpm dlx shadcn@latest add https://www.shadcn-remix.com/r/tooltip.json$ yarn dlx shadcn@latest add https://www.shadcn-remix.com/r/tooltip.jsonInstall the following dependencies:
$ bun add remix$ npm install remix$ pnpm add remix$ yarn add remixCopy and paste the following code into your project.
import { css, on, ref, type Handle, type MixValue, type Props, type RemixNode } from 'remix/component'
import { floatingLayer, type Align, type Side } from './popover.tsx'
// ---------------------------------------------------------------------------------------------
// TooltipProvider: shared delays, and Radix's "skip delay" window — once one tooltip has been
// shown, moving to another trigger within `skipDelayDuration` opens it instantly.
// ---------------------------------------------------------------------------------------------
interface TooltipProviderContext {
delayDuration: number
skipDelayDuration: number
/** Timestamp until which open delays are skipped. */
skipUntil: number
}
const defaultProvider: TooltipProviderContext = { delayDuration: 0, skipDelayDuration: 300, skipUntil: 0 }
export interface TooltipProviderProps {
children?: RemixNode
delayDuration?: number
skipDelayDuration?: number
}
export function TooltipProvider(handle: Handle<TooltipProviderProps, TooltipProviderContext>) {
let context: TooltipProviderContext = {
get delayDuration() {
return handle.props.delayDuration ?? 0
},
get skipDelayDuration() {
return handle.props.skipDelayDuration ?? 300
},
skipUntil: 0,
}
handle.context.set(context)
return () => handle.props.children
}
// ---------------------------------------------------------------------------------------------
// Tooltip
// ---------------------------------------------------------------------------------------------
interface TooltipContext {
readonly open: boolean
readonly contentId: string
trigger: HTMLElement | null
/** Opens after the provider delay (or immediately for focus / inside the skip window). */
scheduleOpen(immediate: boolean): void
/** Closes after a short grace period so the pointer can travel onto the content. */
scheduleClose(): void
cancelClose(): void
setOpen(open: boolean): void
}
export interface TooltipProps {
children?: RemixNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
/** Overrides the provider's `delayDuration` for this tooltip. */
delayDuration?: number
}
export function Tooltip(handle: Handle<TooltipProps, TooltipContext>) {
let provider = handle.context.get(TooltipProvider) ?? defaultProvider
let uncontrolledOpen = handle.props.defaultOpen ?? false
let openTimer = 0
let closeTimer = 0
let context: TooltipContext = {
get open() {
return handle.props.open ?? uncontrolledOpen
},
contentId: `${handle.id}-content`,
trigger: null,
scheduleOpen(immediate) {
context.cancelClose()
clearTimeout(openTimer)
let delay = handle.props.delayDuration ?? provider.delayDuration
if (immediate || delay <= 0 || Date.now() < provider.skipUntil) return context.setOpen(true)
openTimer = window.setTimeout(() => context.setOpen(true), delay)
},
scheduleClose() {
clearTimeout(openTimer)
clearTimeout(closeTimer)
closeTimer = window.setTimeout(() => context.setOpen(false), 100)
},
cancelClose() {
clearTimeout(closeTimer)
},
setOpen(open) {
clearTimeout(openTimer)
clearTimeout(closeTimer)
if (open === context.open) return
if (!open) provider.skipUntil = Date.now() + provider.skipDelayDuration
if (handle.props.open === undefined) {
uncontrolledOpen = open
void handle.update()
}
handle.props.onOpenChange?.(open)
},
}
handle.signal.addEventListener('abort', () => {
clearTimeout(openTimer)
clearTimeout(closeTimer)
})
handle.context.set(context)
return () => handle.props.children
}
// ---------------------------------------------------------------------------------------------
// TooltipTrigger
// ---------------------------------------------------------------------------------------------
export interface TooltipTriggerProps extends Omit<Props<'button'>, 'href' | 'mix'> {
mix?: MixValue<HTMLElement>
/** Renders an `<a>` instead of a `<button>` (shadcn's `asChild` with a link). */
href?: string
target?: string
rel?: string
}
/**
* Renders the element the tooltip describes. shadcn's `asChild` maps to `mix`:
* `<TooltipTrigger mix={buttonVariants({ variant: 'outline' })}>Hover</TooltipTrigger>`.
*/
export function TooltipTrigger(handle: Handle<TooltipTriggerProps>) {
let context = handle.context.get(Tooltip)
// Radix ignores the focus that follows a pointerdown, so clicking a trigger does not reopen it.
let pointerDown = false
let behavior = [
ref<HTMLElement>((node, signal) => {
context.trigger = node
signal.addEventListener('abort', () => {
if (context.trigger === node) context.trigger = null
})
}),
on<HTMLElement, 'pointerenter'>('pointerenter', (event) => {
if (event.pointerType !== 'touch') context.scheduleOpen(false)
}),
on<HTMLElement, 'pointerleave'>('pointerleave', () => context.scheduleClose()),
on<HTMLElement, 'pointerdown'>('pointerdown', () => {
pointerDown = true
context.setOpen(false)
}),
on<HTMLElement, 'pointerup'>('pointerup', () => {
pointerDown = false
}),
on<HTMLElement, 'focus'>('focus', () => {
if (!pointerDown) context.scheduleOpen(true)
}),
on<HTMLElement, 'blur'>('blur', () => context.setOpen(false)),
on<HTMLElement, 'click'>('click', () => context.setOpen(false)),
]
return () => {
let { href, target, rel, role, mix, ...props } = handle.props
let shared = {
'data-slot': 'tooltip-trigger',
'data-state': context.open ? 'delayed-open' : 'closed',
'aria-describedby': context.open ? context.contentId : undefined,
}
if (href !== undefined) {
return (
<a
{...shared}
href={href}
target={target}
rel={rel}
{...(props as Omit<Props<'a'>, 'role'>)}
mix={[behavior, mix]}
/>
)
}
return <button type="button" role={role} {...shared} {...props} mix={[behavior, mix]} />
}
}
// ---------------------------------------------------------------------------------------------
// TooltipContent
// ---------------------------------------------------------------------------------------------
export interface TooltipContentProps extends Props<'div'> {
side?: Side
align?: Align
sideOffset?: number
alignOffset?: number
}
const contentStyles = css({
zIndex: '50',
width: 'fit-content',
transformOrigin: 'var(--radix-tooltip-content-transform-origin)',
borderRadius: 'calc(var(--radius) - 2px)',
backgroundColor: 'var(--foreground)',
paddingInline: '0.75rem',
paddingBlock: '0.375rem',
fontSize: '0.75rem',
lineHeight: 'calc(1 / 0.75)',
textWrap: 'balance',
color: 'var(--background)',
overflow: 'visible',
// animate-in fade-in-0 zoom-in-95 (always, like shadcn) + side slide
animation: 'enter 150ms ease',
'--tw-enter-opacity': '0',
'--tw-enter-scale': '0.95',
'&[data-side="bottom"]': { '--tw-enter-translate-y': '-0.5rem' },
'&[data-side="left"]': { '--tw-enter-translate-x': '0.5rem' },
'&[data-side="right"]': { '--tw-enter-translate-x': '-0.5rem' },
'&[data-side="top"]': { '--tw-enter-translate-y': '0.5rem' },
'&[data-state="closed"]': {
animation: 'exit 150ms ease forwards',
'--tw-exit-opacity': '0',
'--tw-exit-scale': '0.95',
},
})
const arrowWrapperStyles = css({ display: 'block', width: '10px', height: '10px' })
const arrowStyles = css({
zIndex: '50',
width: '0.625rem',
height: '0.625rem',
translate: '0 calc(-50% - 2px)',
rotate: '45deg',
borderRadius: '2px',
backgroundColor: 'var(--foreground)',
fill: 'var(--foreground)',
})
export function TooltipContent(handle: Handle<TooltipContentProps>) {
let context = handle.context.get(Tooltip)
let arrow: HTMLElement | null = null
return () => {
let {
side = 'top',
align = 'center',
sideOffset = 0,
alignOffset = 0,
hidden,
children,
mix,
...props
} = handle.props
let open = context.open && !hidden
return (
<div
data-slot="tooltip-content"
id={context.contentId}
role="tooltip"
data-state={open ? 'delayed-open' : 'closed'}
{...props}
mix={[
contentStyles,
floatingLayer({
open,
anchor: () => context.trigger,
arrow: () => arrow,
name: 'tooltip',
side,
align,
sideOffset,
alignOffset,
inside: (target) => !!context.trigger?.contains(target),
onDismiss: () => context.setOpen(false),
}),
on('pointerenter', () => context.cancelClose()),
on<HTMLElement, 'pointerleave'>('pointerleave', () => context.scheduleClose()),
mix,
]}
>
{children}
<span
mix={[
arrowWrapperStyles,
ref((node) => {
arrow = node
}),
]}
>
<svg width="10" height="5" viewBox="0 0 30 10" preserveAspectRatio="none" mix={arrowStyles}>
<polygon points="0,0 30,0 15,10" />
</svg>
</span>
</div>
)
}
}Update the import paths to match your project setup.
Usage
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '#app/ui/tooltip.tsx'<Tooltip>
<TooltipTrigger>Hover</TooltipTrigger>
<TooltipContent>
<p>Add to library</p>
</TooltipContent>
</Tooltip>API Reference
TooltipProvider
| Prop | Type | Default |
|---|---|---|
delayDuration | number | - |
skipDelayDuration | number | - |
Tooltip
| Prop | Type | Default |
|---|---|---|
open | boolean | - |
defaultOpen | boolean | - |
onOpenChange | ((open: boolean) => void) | - |
delayDuration | number | - |
TooltipTrigger
Renders the element the tooltip describes. shadcn's asChild maps to mix: <TooltipTrigger mix={buttonVariants({ variant: 'outline' })}>Hover</TooltipTrigger>.
| Prop | Type | Default |
|---|---|---|
mix | MixValue | - |
href | string | - |
target | string | - |
rel | string | - |
TooltipContent
| Prop | Type | Default |
|---|---|---|
side | Side | "top" |
align | Align | "center" |
sideOffset | number | 0 |
alignOffset | number | 0 |
mix | MixValue | - |