본문으로 건너뛰기

Tooltip

Prerequisite

npm install classnames

Code

components/Tooltip/index.tsx
import { Children, cloneElement, useRef, useEffect, useMemo } from 'react'
import type { ReactElement, MouseEvent, ReactNode, FC } from 'react'
import classnames from 'classnames'
import { useObjectState } from 'services'
import { Portal } from 'components'

interface Props {
content: ReactNode
position: 'top' | 'right' | 'bottom' | 'left'
border?: boolean
theme?: 'dark' | 'light'
arrow?: boolean
padding?: boolean
animation?: boolean
}
interface State {
isOpen: boolean
triggerTop: number
triggerLeft: number
triggerWidth: number
triggerHeight: number
tooltipWidth: number
tooltipHeight: number
}

const Tooltip: FC<Props> = ({
children,
content,
position,
border,
theme,
arrow,
padding,
animation,
...props
}) => {
const [
{
isOpen,
triggerLeft,
triggerTop,
triggerHeight,
triggerWidth,
tooltipHeight,
tooltipWidth
},
setState,
onChange,
resetState
] = useObjectState<State>({
isOpen: false,
triggerTop: 0,
triggerLeft: 0,
triggerHeight: 0,
triggerWidth: 0,
tooltipHeight: 0,
tooltipWidth: 0
})
const tooltipRef = useRef<HTMLDivElement>(null)
const child = Children.only(
typeof children === 'string' ? (
<div tabIndex={-1}>{children}</div>
) : (
children
)
) as ReactElement
const trigger = cloneElement(child, {
...props,
className: 'inline-block',
onMouseEnter: (e: MouseEvent) => {
const element = e.target as HTMLElement
const { height, width, top, left } = element.getBoundingClientRect()
setState({
isOpen: true,
triggerLeft: left,
triggerTop: top,
triggerHeight: height,
triggerWidth: width
})
},
onMouseLeave: () => resetState()
})

const left: number = useMemo(() => {
if (position === 'top' || position === 'bottom')
return triggerLeft + triggerWidth / 2 - tooltipWidth / 2
else if (position === 'left') return triggerLeft - tooltipWidth - 10
else if (position === 'right') return triggerLeft + triggerWidth + 10
return 0
}, [triggerLeft, triggerWidth, tooltipWidth])

const top: number = useMemo(() => {
if (position === 'top') return triggerTop - tooltipHeight - 10
else if (position === 'bottom') return triggerTop + tooltipHeight
else if (position === 'left' || position === 'right')
return triggerTop + triggerHeight / 2 - tooltipHeight / 2
return 0
}, [triggerTop, tooltipHeight])

const isPositioned: boolean = useMemo(() => {
return !!tooltipWidth && !!tooltipHeight
}, [left, top])
useEffect(() => {
if (isOpen && tooltipRef.current) {
const { height, width } = tooltipRef.current.getBoundingClientRect()
setState({
tooltipHeight: height,
tooltipWidth: width
})
}
}, [isOpen, tooltipRef])
return (
<>
{trigger}
{isOpen && (
<Portal>
<div
ref={tooltipRef}
className={classnames(
'fixed rounded z-[9999]',
isPositioned ? 'visible' : 'invisible',
{
'px-5 py-2': padding,
'after:content-[""] after:absolute after:border-8': arrow,
'border border-gray-200': border,
'before-content-[""] before:absolute before:border-[9px]':
arrow && border,

'bg-gray-100 text-gray-700': theme === 'light',
'bg-black text-white': theme === 'dark',

'after:border-t-gray-100':
arrow && theme === 'light' && position === 'top',
'after:border-b-gray-100':
arrow && theme === 'light' && position === 'bottom',
'after:border-l-gray-100':
arrow && theme === 'light' && position === 'left',
'after:border-r-gray-100':
arrow && theme === 'light' && position === 'right',
'after:border-t-black':
arrow && theme === 'dark' && position === 'top',
'after:border-b-black':
arrow && theme === 'dark' && position === 'bottom',
'after:border-l-black':
arrow && theme === 'dark' && position === 'left',
'after:border-r-black':
arrow && theme === 'dark' && position === 'right',

'after:bottom-full after:border-t-transparent':
arrow && position === 'bottom',
'after:top-full after:border-b-transparent':
arrow && position === 'top',
'after:-ml-2 after:left-1/2 after:border-x-transparent':
arrow && (position === 'top' || position === 'bottom'),

'after:left-full after:border-r-transparent':
arrow && position === 'left',
'after:right-full after:border-l-transparent':
arrow && position === 'right',
'after:-mt-2 after:top-1/2 after:border-y-transparent':
arrow && (position === 'left' || position === 'right'),

'before:bottom-full before:border-t-transparent':
arrow && border && position === 'bottom',
'before:top-full before:border-b-transparent':
arrow && border && position === 'top',
'before:left-full before:border-r-transparent':
arrow && border && position === 'left',
'before:right-full before:border-l-transparent':
arrow && border && position === 'right',

'before:border-x-transparent before:-ml-2 before:left-[calc(50%-1px)]':
arrow &&
border &&
(position === 'top' || position === 'bottom'),
'before:border-y-transparent before:-mt-2 before:top-[calc(50%-1px)]':
arrow &&
border &&
(position === 'left' || position === 'right'),

'before:border-b-gray-200':
arrow && border && theme === 'light' && position === 'bottom',
'before:border-b-black':
arrow && border && theme === 'dark' && position === 'bottom',
'before:border-t-gray-200':
arrow && border && theme === 'light' && position === 'top',
'before:border-t-black':
arrow && border && theme === 'dark' && position === 'top',
'before:border-l-gray-200':
arrow && border && theme === 'light' && position === 'left',
'before:border-l-black':
arrow && border && theme === 'dark' && position === 'left',
'before:border-r-gray-200':
arrow && border && theme === 'light' && position === 'right',
'before:border-r-black':
arrow && border && theme === 'dark' && position === 'right'
}
)}
style={{ left, top }}
role="tooltip"
>
{content}
</div>
</Portal>
)}
</>
)
}

export default Tooltip

Props

NameTypeDefault
content*string
position*top right bottom left
borderbooleantrue
themedark lightlight
arrowbooleantrue
paddingbooleantrue
animationbooleantrue

Example