Let's make a tooltip with React and PopperJS. To demonstrate it, we'll implement a UX pattern, showing a hint when hovering a disabled button.
isDisabled?: boolean | string;
The components has isDisabled
property that could be either boolean
or a string
so we don't need to pass an extra property to include a hint for a disabled button.
export const RectButton = ({
children,
size = "m",
isDisabled = false,
isLoading = false,
onClick,
onMouseEnter,
onMouseLeave,
...rest
}: Props) => {
const [anchor, setAnchor] = useState<HTMLElement | null>(null)
const [isTooltipOpen, { unset: hideTooltip, set: showTooltip }] =
useBoolean(false)
// ...
}
To show the tooltip, we'll need a ref of the button container and a boolean state to know if the user hovers the element. We'll show the tooltip only if isDisabled
is a string. To update the isTooltipOpen
we all add onMouseEnter
and onMouseLeave
event handlers.
const isTooltipEnabled = typeof isDisabled === "string"
return (
<Container
size={size}
isDisabled={!!isDisabled}
isLoading={isLoading}
onClick={isDisabled || isLoading ? undefined : onClick}
onMouseEnter={(event: MouseEvent<HTMLButtonElement>) => {
onMouseEnter?.(event)
isTooltipEnabled && showTooltip()
}}
onMouseLeave={(event: MouseEvent<HTMLButtonElement>) => {
onMouseLeave?.(event)
isTooltipEnabled && hideTooltip()
}}
ref={setAnchor}
{...rest}
>
{isLoading ? (
<div>
<Spinner />
</div>
) : (
<>{children}</>
)}
{anchor && isTooltipOpen && (
<Popover placement="bottom" anchor={anchor}>
<TooltipContainer>{isDisabled}</TooltipContainer>
</Popover>
)}
</Container>
)
If we have the anchor and the tooltip is open, we'll show the tooltip wrapped with the Popover component. We set reversed text and background colors to the tooltip, so it stands out on both dark and light themes. We add keyframes with up movement and opacity for a smoother appearance.
const tooltipAnimation = keyframes`
from {
transform: translateY(4px);
opacity: 0.6;
}
`
const TooltipContainer = styled.div`
border-radius: 4px;
padding: 4px 8px;
background: ${({ theme }) =>
theme.colors.text.getVariant({ a: () => 1 }).toCssValue()};
color: ${({ theme }) => theme.colors.background.toCssValue()};
font-size: 14px;
animation: ${tooltipAnimation} 300ms ease-out;
`
Here we set up the Popover
component with PopperJS, add a handler for clicking outside, force update on size change, and render everything inside of a body portal.
import { Placement } from "@popperjs/core"
import { ReactNode, useEffect, useState } from "react"
import { usePopper } from "react-popper"
import { useClickAway } from "react-use"
import styled from "styled-components"
import { BodyPortal } from "lib/ui/BodyPortal"
import { useElementSize } from "lib/ui/hooks/useElementSize"
import { ScreenCover } from "lib/ui/ScreenCover"
import { zIndex } from "lib/ui/zIndex"
import { useValueRef } from "lib/shared/hooks/useValueRef"
export type PopoverPlacement = Placement
interface PopoverProps {
anchor: HTMLElement
children: ReactNode
placement?: PopoverPlacement
distance?: number
enableScreenCover?: boolean
onClickOutside?: () => void
}
export const Popover = styled(
({
anchor,
children,
onClickOutside,
placement = "auto",
distance = 4,
enableScreenCover = false,
}: PopoverProps) => {
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null)
const { styles, attributes, update } = usePopper(anchor, popperElement, {
placement,
strategy: "fixed",
modifiers: [
{
name: "offset",
options: {
offset: [0, distance],
},
},
{
name: "preventOverflow",
options: {
padding: 8,
},
},
],
})
const poperRef = useValueRef(popperElement)
useClickAway(poperRef, (event) => {
if (anchor.contains(event.target as Node)) return
onClickOutside?.()
})
const size = useElementSize(popperElement)
useEffect(() => {
if (!update) return
update()
}, [size, update])
const popoverNode = (
<Container
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
{children}
</Container>
)
return (
<BodyPortal>
{enableScreenCover && <ScreenCover />}
{popoverNode}
</BodyPortal>
)
}
)``
const Container = styled.div`
position: relative;
z-index: ${zIndex.menu};
`