overlaysclient
tooltip
a short label that shows up when you hover or focus something.
installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add tooltipor by hand: drop this file into components/ui and import from there.
components/ui/Tooltip.tsxtsx
1"use client";2
3import React, { useState, useRef, useCallback, useEffect, useId } from "react";4
5type TooltipPosition = "top" | "bottom" | "left" | "right";6
7interface TooltipProps {8 content: string;9 position?: TooltipPosition;10 children: React.ReactNode;11 className?: string;12}13
14const origin: Record<TooltipPosition, string> = {15 top: "origin-bottom",16 bottom: "origin-top",17 left: "origin-right",18 right: "origin-left",19};20
21const enterTranslate: Record<TooltipPosition, string> = {22 top: "translate-y-1",23 bottom: "-translate-y-1",24 left: "translate-x-1",25 right: "-translate-x-1",26};27
28export function Tooltip({29 content,30 position = "top",31 children,32 className = "",33}: TooltipProps) {34 const [open, setOpen] = useState(false);35 const [visible, setVisible] = useState(false);36 const [mounted, setMounted] = useState(false);37 const [coords, setCoords] = useState({ top: 0, left: 0 });38 const triggerRef = useRef<HTMLDivElement>(null);39 const tooltipRef = useRef<HTMLDivElement>(null);40 const enterTimer = useRef<ReturnType<typeof setTimeout> | null>(null);41 const exitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);42 const autoHideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);43 const touchActiveRef = useRef(false);44 const uid = useId();45
46 const hide = useCallback(() => {47 if (enterTimer.current) clearTimeout(enterTimer.current);48 if (autoHideTimer.current) clearTimeout(autoHideTimer.current);49 setVisible(false);50 exitTimer.current = setTimeout(() => {51 setMounted(false);52 setOpen(false);53 }, 150);54 }, []);55
56 const show = useCallback((fromTouch = false) => {57 if (exitTimer.current) clearTimeout(exitTimer.current);58 if (autoHideTimer.current) clearTimeout(autoHideTimer.current);59 enterTimer.current = setTimeout(() => setOpen(true), fromTouch ? 0 : 80);60 if (fromTouch) {61 autoHideTimer.current = setTimeout(() => {62 touchActiveRef.current = false;63 hide();64 }, 2400);65 }66 }, [hide]);67
68 useEffect(() => {69 if (open) {70 setMounted(true);71 requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));72 }73 }, [open]);74
75 const measure = useCallback(() => {76 if (!triggerRef.current || !tooltipRef.current) return;77 const tr = triggerRef.current.getBoundingClientRect();78 const tp = tooltipRef.current.getBoundingClientRect();79 const gap = 7;80
81 let top = 0;82 let left = 0;83
84 switch (position) {85 case "top":86 top = -(tp.height + gap);87 left = (tr.width - tp.width) / 2;88 break;89 case "bottom":90 top = tr.height + gap;91 left = (tr.width - tp.width) / 2;92 break;93 case "left":94 top = (tr.height - tp.height) / 2;95 left = -(tp.width + gap);96 break;97 case "right":98 top = (tr.height - tp.height) / 2;99 left = tr.width + gap;100 break;101 }102
103 const viewportPadding = 8;104 const absLeft = triggerRef.current.getBoundingClientRect().left + left;105 const overflowRight = absLeft + tp.width - (window.innerWidth - viewportPadding);106 const overflowLeft = viewportPadding - absLeft;107 if (overflowRight > 0) left -= overflowRight;108 if (overflowLeft > 0) left += overflowLeft;109
110 setCoords({ top, left });111 }, [position]);112
113 useEffect(() => {114 if (mounted) measure();115 }, [mounted, measure]);116
117 useEffect(() => {118 if (!open) return;119 const onDocPointer = (e: PointerEvent) => {120 if (!triggerRef.current?.contains(e.target as Node)) {121 touchActiveRef.current = false;122 hide();123 }124 };125 document.addEventListener("pointerdown", onDocPointer);126 return () => document.removeEventListener("pointerdown", onDocPointer);127 }, [open, hide]);128
129 // Escape dismisses without moving focus (WCAG 1.4.13)130 useEffect(() => {131 if (!open) return;132 const onKey = (e: KeyboardEvent) => {133 if (e.key === "Escape") hide();134 };135 document.addEventListener("keydown", onKey);136 return () => document.removeEventListener("keydown", onKey);137 }, [open, hide]);138
139 useEffect(() => {140 return () => {141 if (enterTimer.current) clearTimeout(enterTimer.current);142 if (exitTimer.current) clearTimeout(exitTimer.current);143 if (autoHideTimer.current) clearTimeout(autoHideTimer.current);144 };145 }, []);146
147 const handlePointerEnter = (e: React.PointerEvent) => {148 if (e.pointerType === "touch") return;149 show(false);150 };151
152 const handlePointerLeave = (e: React.PointerEvent) => {153 if (e.pointerType === "touch") return;154 if (touchActiveRef.current) return;155 hide();156 };157
158 const handlePointerDown = (e: React.PointerEvent) => {159 if (e.pointerType !== "touch") return;160 touchActiveRef.current = true;161 if (open) {162 hide();163 } else {164 show(true);165 }166 };167
168 return (169 <div170 ref={triggerRef}171 className={["relative inline-flex", className].join(" ")}172 onPointerEnter={handlePointerEnter}173 onPointerLeave={handlePointerLeave}174 onPointerDown={handlePointerDown}175 onFocus={() => show(false)}176 onBlur={() => {177 if (!touchActiveRef.current) hide();178 }}179 >180 {React.isValidElement(children)181 ? React.cloneElement(children as React.ReactElement<Record<string, unknown>>, {182 "aria-describedby": mounted ? `${uid}-tip` : undefined,183 })184 : children}185 {mounted && (186 <div187 ref={tooltipRef}188 id={`${uid}-tip`}189 role="tooltip"190 className={[191 "absolute z-50 px-2.5 py-1.5 rounded-[9px] text-white text-[11.5px] font-medium whitespace-nowrap pointer-events-none",192 "transition-[opacity,transform,scale,translate] duration-150 ease-[var(--ease-spring)]",193 origin[position],194 visible195 ? "opacity-100 scale-100 translate-x-0 translate-y-0"196 : ["opacity-0 scale-95", enterTranslate[position]].join(" "),197 ].join(" ")}198 style={{199 top: coords.top,200 left: coords.left,201 background: "var(--face-dark)",202 boxShadow:203 "0 0 3px rgba(0,0,0,0.35), 0 2px 6px rgba(0,0,0,0.22), 0 8px 24px -4px rgba(0,0,0,0.3), inset 0 2px 3px -2px rgba(255,255,255,0.105)",204 textShadow: "0 -1px 0 rgba(0,0,0,0.4)",205 }}206 >207 {content}208 </div>209 )}210 </div>211 );212}usage
tsx
import { Tooltip } from "@blurrow/ui";tsx
<Tooltip content="edit post"><Button>edit</Button></Tooltip>examples
positions
props
straight from the source, so they're always up to date.
propsts
type TooltipPosition = "top" | "bottom" | "left" | "right";
interface TooltipProps { content: string; position?: TooltipPosition; children: React.ReactNode; className?: string;}