A universal, headless React popper component powered by Floating UI. Build tooltips, dropdowns, selects, comboboxes, and more — all with one flexible component.
npm install react-uni-popperbash
npm install react-uni-popper
or
yarn add react-uni-popper
or
pnpm add react-uni-popper
`
Quick Start
`tsx
import React, { useRef, useState } from 'react';
import ReactUniPopper from 'react-uni-popper';
function TooltipExample() {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef(null);
return (
ref={buttonRef}
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
Hover me
{isOpen && (
reference={buttonRef.current}
placement="top"
offset={8}
arrow
arrowSize={8}
>
This is a tooltip!
)}
);
}
`
API Reference
$3
| Prop | Type | Default | Description |
| ----------------- | ----------------------------- | --------------- | ----------------------------------------------- |
| reference | HTMLElement \| null | required | The reference element to position relative to |
| children | ReactNode \| RenderFunction | required | Content to render or render function |
| placement | PositionType | 'bottom' | Preferred placement direction |
| offset | number | 4 | Distance between reference and floating element |
| arrow | boolean | false | Whether to show positioning arrow |
| arrowSize | number | 8 | Size of the arrow in pixels |
| portalContainer | HTMLElement | document.body | Container to render the portal in |
| zIndex | number | undefined | CSS z-index value |
$3
`typescript
type PositionType =
| 'top'
| 'right'
| 'bottom'
| 'left'
| 'top-start'
| 'top-end'
| 'right-start'
| 'right-end'
| 'bottom-start'
| 'bottom-end'
| 'left-start'
| 'left-end';
`
$3
The children prop can be a render function that receives positioning data:
`tsx
{({ placement, arrowStyles, floatingStyles, arrowRef }) => (
Content here
{arrow && }
)}
`
Headless UI Integration
This package is specifically designed to solve portal issues with Headless UI components. Here are examples of how to integrate it with various Headless UI components:
$3
`tsx
import React, { useMemo, useRef, useState } from 'react';
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
ListboxProps,
} from '@headlessui/react';
import ReactUniPopper from 'react-uni-popper';
interface SelectOption {
name: string;
value: string | number;
className?: string;
disabled?: boolean;
}
interface SelectProps extends Omit, 'children'> {
options: SelectOption[];
renderOption?: (option: SelectOption) => React.ReactNode;
popperClassName?: string;
portalContainer?: HTMLElement;
}
function Select({
options,
className,
value,
renderOption,
popperClassName,
portalContainer = document.body,
...props
}: SelectProps) {
const selectedValue = !value ? options[0].value : value;
const selectedOption = options.find(
(option) => option.value === selectedValue,
);
const buttonRef = useRef(null);
return (
{({ open }) => (
<>
ref={buttonRef}
className={ flex h-9 min-h-9 w-full items-center justify-between text-nowrap rounded-lg border border-gray-300 bg-white px-4 text-sm text-gray-700 ${className || ''}}
>
{selectedOption?.name}
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
{open && (
reference={buttonRef.current}
portalContainer={portalContainer}
zIndex={1300}
placement="bottom-start"
offset={4}
>
style={{
width: buttonRef.current?.offsetWidth,
}}
className={max-h-60 overflow-auto rounded-lg border bg-white shadow-lg ${popperClassName || ''}}
>
{options.map((option) => (
key={option.value}
className={cursor-pointer text-nowrap px-4 py-2 text-sm font-medium text-gray-700 data-[focus]:bg-blue-100 ${option.className || ''}}
disabled={option.disabled}
value={option.value}
>
{renderOption ? (
renderOption(option)
) : (
{option.name}
)}
))}
)}
>
)}
);
}
export default Select;
`
$3
`tsx
import React, { useRef, useState } from 'react';
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
ComboboxProps,
} from '@headlessui/react';
import ReactUniPopper from 'react-uni-popper';
interface ComboboxOption {
name: string;
value: string | number;
className?: string;
disabled?: boolean;
}
interface CustomComboboxProps extends Omit, 'children'> {
options: ComboboxOption[];
renderOption?: (option: ComboboxOption) => React.ReactNode;
popperClassName?: string;
portalContainer?: HTMLElement;
}
function CustomCombobox({
options,
className,
value,
onChange,
renderOption,
popperClassName,
portalContainer = document.body,
...props
}: CustomComboboxProps) {
const [query, setQuery] = useState('');
const buttonRef = useRef(null);
const filteredOptions =
query === ''
? options
: options.filter((option) =>
option.name.toLowerCase().includes(query.toLowerCase()),
);
return (
{({ open }) => (
<>
className={w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 ${className || ''}}
onChange={(event) => setQuery(event.target.value)}
displayValue={(option: ComboboxOption) => option?.name}
/>
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
{open && (
reference={buttonRef.current}
portalContainer={portalContainer}
zIndex={1300}
placement="bottom-start"
offset={4}
>
className={ max-h-60 overflow-auto rounded-lg border bg-white shadow-lg ${popperClassName || ''}}
>
{filteredOptions.map((option) => (
key={option.value}
className={cursor-pointer px-4 py-2 text-sm font-medium text-gray-700 data-[focus]:bg-blue-100 ${option.className || ''}}
disabled={option.disabled}
value={option}
>
{renderOption ? (
renderOption(option)
) : (
{option.name}
)}
))}
)}
>
)}
);
}
export default CustomCombobox;
`
$3
`tsx
import React, { useRef, useState } from 'react';
import {
Menu,
MenuButton,
MenuItem,
MenuItems,
MenuProps,
} from '@headlessui/react';
import ReactUniPopper from 'react-uni-popper';
interface MenuOption {
name: string;
value: string | number;
onClick?: () => void;
className?: string;
disabled?: boolean;
}
interface CustomDropdownProps extends Omit, 'children'> {
options: MenuOption[];
renderOption?: (option: MenuOption) => React.ReactNode;
popperClassName?: string;
portalContainer?: HTMLElement;
buttonContent?: React.ReactNode;
}
function CustomDropdown({
options,
renderOption,
popperClassName,
portalContainer = document.body,
buttonContent = 'Open Menu',
...props
}: CustomDropdownProps) {
const buttonRef = useRef(null);
return (
min-w-48 rounded-lg border bg-white shadow-lg p-2 ${popperClassName || ''}}
>
{options.map((option) => (
key={option.value}
disabled={option.disabled}
className={cursor-pointer rounded px-3 py-2 text-sm font-medium text-gray-700 data-[focus]:bg-blue-100 ${option.className || ''}}
onClick={option.onClick}
>
{renderOption ? (
renderOption(option)
) : (
{option.name}
)}
))}
)}
>
)}
);
}
export default CustomDropdown;
`
Styling
The component renders with a headless-popper class for custom styling:
`css
.headless-popper {
/ Your custom styles /
}
.headless-popper .arrow {
/ Arrow styles /
position: absolute;
width: 8px;
height: 8px;
background: inherit;
transform: rotate(45deg);
}
``