Let's make a beautiful reusable tooltip component with React and Floating UI. It will automatically reposition itself when there is not enough space, have an arrow pointing to a referencing element, and look good in both dark and light modes.
The Tooltip
component receives a content
prop with the text to display inside the tooltip, a renderOpener
prop with a function that returns a React element that will open the tooltip when hovered, and a placement
prop with the preferred placement of the tooltip relative to the opener.
import { ReactNode, useRef, useState } from "react"
import {
ReferenceType,
offset,
shift,
flip,
useFloating,
autoUpdate,
useInteractions,
useHover,
useFocus,
useDismiss,
useRole,
arrow,
FloatingArrow,
useTransitionStyles,
Placement,
} from "@floating-ui/react"
import styled from "styled-components"
import { getColor } from "./theme/getters"
export interface RenderOpenerProps extends Record<string, unknown> {
ref: (node: ReferenceType | null) => void
}
interface TooltipProps {
content?: ReactNode
renderOpener: (props: RenderOpenerProps) => ReactNode
placement?: Placement
}
const Container = styled.div`
border-radius: 8px;
background: ${getColor("contrast")};
color: ${getColor("background")};
padding: 12px;
font-size: 14px;
font-weight: 500;
max-width: 320px;
`
const Arrow = styled(FloatingArrow)`
fill: ${getColor("contrast")};
`
export const Tooltip = ({ content, renderOpener, placement }: TooltipProps) => {
const [isOpen, setIsOpen] = useState(false)
const {
refs: { setReference, setFloating },
floatingStyles,
context,
} = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement,
middleware: [
offset(12),
flip(),
shift(),
arrow({
element: arrowRef,
}),
],
whileElementsMounted: autoUpdate,
})
const arrowRef = useRef(null)
const hover = useHover(context, { move: false })
const focus = useFocus(context)
const dismiss = useDismiss(context)
const role = useRole(context, { role: "tooltip" })
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
])
const { styles: transitionStyles } = useTransitionStyles(context, {
initial: {
opacity: 0,
transform: "scale(0.8)",
},
})
return (
<>
{renderOpener({ ref: setReference, ...getReferenceProps() })}
{isOpen && content && (
<div
ref={setFloating}
style={{ ...floatingStyles, zIndex: 1 }}
{...getFloatingProps()}
>
<Container style={transitionStyles}>
<Arrow tipRadius={2} height={8} ref={arrowRef} context={context} />
{content}
</Container>
</div>
)}
</>
)
}
We store the opened/closed state in the useState hook. All the magic for positining the floating element lies in the useFloating
hook. We pass the isOpen
state and the setIsOpen
function to the hook, so it can update the state when the user hovers over the opener, we also propagate the placement
prop to the hook, so it knows where to position the tooltip, and middleware for modifying the position of the tooltip. In this case offset creates a distance between the opener and tooltip, flip
and shift
make sure that the tooltip gets repositioned when there is not enough space, and arrow
creates an arrow pointing to the opener.
To trigger appearance on hover we rely on the useHover
hook, to also display the tooltip on focus we have the useFocus
hook, and to hide the tooltip on escape key press - the useDismiss
hook. To get properties for accessibility purposes we apply the useRole
hook. To combine all those effects we use the useInteractions
hook.
To apply animation for appearance there is the useTransitionStyles
hook. It returns a styles
object that we can apply to the tooltip container. To make tooltip stand out on both dark and light mode we reverse colors by making text color the same as the background color and vice versa.
Finally we render the opener and the tooltip itself. We pass all the props and ref to the renderOpener
function, and when the isOpen
is true
we render the tooltip itself. We apply position styles to a regular div, while passing transitionStyles
to the tooltip container.