@slithy/base-ui
v0.4.0
Published
UI components for @slithy, built on Base UI.
Readme
@slithy/base-ui
Compound UI components built on Base UI. Tooltip, Menu, and Popover all use a global singleton architecture: each trigger is a plain <button> with zero Base UI overhead, and a single renderer mounted at the app root owns the positioning and rendering.
Setup
Mount all renderers once at the app root. Timing props on TooltipRenderer are global defaults — all tooltips share them.
import { MenuRenderer, PopoverRenderer, TooltipRenderer } from "@slithy/base-ui";
function App() {
return (
<>
<MenuRenderer />
<PopoverRenderer />
<TooltipRenderer delay={600} closeDelay={300} timeout={300} />
{/* rest of your app */}
</>
);
}TooltipRenderer
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| delay | number | 600 | Delay before opening in ms. |
| closeDelay | number | 300 | Delay before closing in ms. |
| timeout | number | 300 | Warm-up window in ms — if a tooltip closed within this window, the next opens instantly. |
Tooltip
import { Tooltip } from "@slithy/base-ui";
<Tooltip.Root>
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Popup>
<Tooltip.Arrow />
Tooltip content
</Tooltip.Popup>
</Tooltip.Portal>
</Tooltip.Root>Parts: Root, Trigger, Portal, Popup, Arrow
Controlled open
Control the tooltip's open state externally with open and onOpenChange:
function ControlledTooltip() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen((o) => !o)}>
{open ? "Hide" : "Show"} tooltip
</button>
<Tooltip.Root open={open} onOpenChange={setOpen}>
<Tooltip.Trigger>Target</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Popup>Controlled tooltip</Tooltip.Popup>
</Tooltip.Portal>
</Tooltip.Root>
</>
);
}Detached trigger
When the trigger and tooltip content need to live in different parts of the tree, provide a matching id on both Root and Trigger:
function DetachedTooltip() {
const [open, setOpen] = useState(false);
return (
<>
<Tooltip.Trigger id="help-tip">Help</Tooltip.Trigger>
<Tooltip.Root id="help-tip" open={open} onOpenChange={setOpen}>
<Tooltip.Portal>
<Tooltip.Popup>Detached tooltip content</Tooltip.Popup>
</Tooltip.Portal>
</Tooltip.Root>
<button onClick={() => setOpen(true)}>Show help</button>
</>
);
}Detached triggers require controlled mode (open/onOpenChange) since the trigger is outside Root's context.
Props
Root
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| id | string | — | Explicit trigger id. Required for detached triggers; auto-generated otherwise. |
| open | boolean | — | Controlled open state. |
| defaultOpen | boolean | — | Open on first render (uncontrolled). |
| disabled | boolean | false | Prevent the tooltip from opening. |
| side | "top" \| "bottom" \| "left" \| "right" | "top" | Which side of the trigger to place the popup. |
| sideOffset | number | 6 | Distance between trigger and popup in pixels. |
| align | "start" \| "center" \| "end" | "center" | Alignment relative to the trigger. |
| alignOffset | number | 0 | Offset along the alignment axis in pixels. |
| collisionPadding | number \| Partial<Record<Side, number>> | 5 | Padding from viewport edges for collision detection. |
| onOpenChange | (open: boolean) => void | — | Called when the tooltip opens or closes. |
| onOpenChangeComplete | (open: boolean) => void | — | Called after open/close animation completes. |
Trigger
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| render | ReactElement \| (props) => ReactElement | — | Replace the default <button> with a custom element. See Custom trigger element. |
Popover
Interactive hover card backed by Base UI's Popover with openOnHover. Unlike Tooltip (non-interactive, text-only), Popover supports rich interactive content — links, buttons, media — that stays open while the pointer is over the popup. Also supports click-triggered mode.
import { Popover } from "@slithy/base-ui";
<Popover.Root side="bottom" sideOffset={8}>
<Popover.Trigger delay={300} closeDelay={200}>
@username
</Popover.Trigger>
<Popover.Portal>
<Popover.Popup>
<Popover.Arrow />
<Popover.Title>User Profile</Popover.Title>
<Popover.Description>Hover card with interactive content.</Popover.Description>
</Popover.Popup>
</Popover.Portal>
</Popover.Root>Parts: Root, Trigger, Portal, Popup, Arrow, Close, Title, Description
Per-trigger timing
Unlike Tooltip where timing is global, each Popover trigger can specify its own delay and closeDelay:
<Popover.Root delay={0} closeDelay={300}>
<Popover.Trigger>Instant open</Popover.Trigger>
...
</Popover.Root>
<Popover.Root delay={600}>
<Popover.Trigger>Slow open</Popover.Trigger>
...
</Popover.Root>Click-triggered
Set openOnHover={false} to create a click-triggered popover. Use Popover.Close for a dismiss button:
<Popover.Root openOnHover={false}>
<Popover.Trigger>Click me</Popover.Trigger>
<Popover.Portal>
<Popover.Popup>
<Popover.Close>✕</Popover.Close>
Click-triggered popover content
</Popover.Popup>
</Popover.Portal>
</Popover.Root>To open on both hover and click, set both explicitly:
<Popover.Root openOnHover={true} openOnClick={true}>
...
</Popover.Root>Controlled open
function ControlledPopover() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen((o) => !o)}>
{open ? "Close" : "Open"}
</button>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger>Target</Popover.Trigger>
<Popover.Portal>
<Popover.Popup>Controlled popover</Popover.Popup>
</Popover.Portal>
</Popover.Root>
</>
);
}Props
Root
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| id | string | — | Explicit trigger id. Required for detached triggers; auto-generated otherwise. |
| open | boolean | — | Controlled open state. |
| defaultOpen | boolean | — | Open on first render (uncontrolled). |
| disabled | boolean | false | Prevent the popover from opening. |
| modal | boolean | false | Whether the popover is modal (locks scroll, traps focus). |
| openOnHover | boolean | true | Whether the trigger opens on hover. Set to false for click-only. |
| openOnClick | boolean | !openOnHover | Whether the trigger opens on click. Defaults to false when openOnHover is true; set both to true to open on hover and click. |
| delay | number | 300 | Delay before opening on hover in ms. |
| closeDelay | number | 0 | Delay before closing on hover in ms. |
| side | "top" \| "bottom" \| "left" \| "right" | "bottom" | Which side of the trigger to place the popup. |
| sideOffset | number | 8 | Distance between trigger and popup in pixels. |
| align | "start" \| "center" \| "end" | "center" | Alignment relative to the trigger. |
| alignOffset | number | 0 | Offset along the alignment axis in pixels. |
| collisionPadding | number \| Partial<Record<Side, number>> | 5 | Padding from viewport edges for collision detection. |
| onOpenChange | (open: boolean) => void | — | Called when the popover opens or closes. |
| onOpenChangeComplete | (open: boolean) => void | — | Called after open/close animation completes. |
Trigger
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| render | ReactElement \| (props) => ReactElement | — | Replace the default <button> with a custom element. See Custom trigger element. |
| nativeButton | boolean | auto | Whether the rendered element is a native <button>. Auto-detected from render. |
| openOnHover | boolean | — | Override openOnHover from Root. |
| delay | number | — | Override delay from Root. |
| closeDelay | number | — | Override closeDelay from Root. |
Menu
import { Menu } from "@slithy/base-ui";
<Menu.Root>
<Menu.Trigger>Open menu</Menu.Trigger>
<Menu.Portal>
<Menu.Popup>
<Menu.Item onClick={handleEdit}>Edit</Menu.Item>
<Menu.Item onClick={handleDuplicate}>Duplicate</Menu.Item>
<Menu.Separator />
<Menu.Item onClick={handleDelete}>Delete</Menu.Item>
</Menu.Popup>
</Menu.Portal>
</Menu.Root>Parts: Root, Trigger, Portal, Popup, Arrow, Item, Separator, Group, GroupLabel, CheckboxItem, CheckboxItemIndicator, RadioGroup, RadioItem, RadioItemIndicator, SubmenuRoot, SubmenuTrigger, SubmenuPortal, Positioner
Tooltip on trigger
Menu.Trigger can show a tooltip on hover/focus that is automatically dismissed when the menu opens:
<Menu.Trigger tooltip="Edit, duplicate, or delete">
Actions
</Menu.Trigger>Requires TooltipRenderer to be mounted. Touch interactions do not trigger tooltips.
Disabling the menu
Set disabled on Root to prevent the menu from opening while keeping the trigger interactive:
<Menu.Root disabled={isMobile}>
<Menu.Trigger onClick={isMobile ? () => setModalOpen(true) : undefined}>
Options
</Menu.Trigger>
<Menu.Portal>
<Menu.Popup>
<Menu.Item onClick={handleEdit}>Edit</Menu.Item>
</Menu.Popup>
</Menu.Portal>
</Menu.Root>Nested menus
Use SubmenuRoot, SubmenuTrigger, and SubmenuPortal to nest a submenu inside a popup. Wrap the Positioner directly inside SubmenuPortal to control placement:
<Menu.Root>
<Menu.Trigger>Actions</Menu.Trigger>
<Menu.Portal>
<Menu.Popup>
<Menu.Item onClick={handleEdit}>Edit</Menu.Item>
<Menu.SubmenuRoot>
<Menu.SubmenuTrigger>Move to ›</Menu.SubmenuTrigger>
<Menu.SubmenuPortal>
<Menu.Positioner side="right" align="start" sideOffset={4}>
<Menu.Popup>
<Menu.Item onClick={() => moveTo("inbox")}>Inbox</Menu.Item>
<Menu.Item onClick={() => moveTo("archive")}>Archive</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.SubmenuPortal>
</Menu.SubmenuRoot>
</Menu.Popup>
</Menu.Portal>
</Menu.Root>Controlled open
Control the menu's open state externally with open and onOpenChange:
function ControlledMenu() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen((o) => !o)}>
{open ? "Close" : "Open"}
</button>
<Menu.Root open={open} onOpenChange={setOpen}>
<Menu.Trigger>Actions</Menu.Trigger>
<Menu.Portal>
<Menu.Popup>
<Menu.Item>Edit</Menu.Item>
<Menu.Item>Delete</Menu.Item>
</Menu.Popup>
</Menu.Portal>
</Menu.Root>
</>
);
}Detached trigger
When the trigger and menu content need to live in different parts of the tree, provide a matching id on both Root and Trigger:
function DetachedMenu() {
const [open, setOpen] = useState(false);
return (
<>
{/* Trigger rendered elsewhere */}
<Menu.Trigger id="project-actions">Actions</Menu.Trigger>
{/* Root elsewhere */}
<Menu.Root id="project-actions" open={open} onOpenChange={setOpen}>
<Menu.Portal>
<Menu.Popup>
<Menu.Item>Edit</Menu.Item>
<Menu.Item>Delete</Menu.Item>
</Menu.Popup>
</Menu.Portal>
</Menu.Root>
{/* Programmatic open */}
<button onClick={() => setOpen(true)}>Open it</button>
</>
);
}Detached triggers require controlled mode (open/onOpenChange) since the trigger is outside Root's context.
Props
Root
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| id | string | — | Explicit trigger id. Required for detached triggers; auto-generated otherwise. |
| open | boolean | — | Controlled open state. |
| defaultOpen | boolean | — | Open on first render (uncontrolled). |
| disabled | boolean | false | Prevent the menu from opening. The trigger remains interactive. |
| modal | boolean | true | Whether the menu is modal (locks scroll, inerts page). When false, the popup auto-dismisses when the trigger scrolls out of view. |
| side | "top" \| "bottom" \| "left" \| "right" | "bottom" | Which side of the trigger to place the popup. |
| sideOffset | number | 4 | Distance between trigger and popup in pixels. |
| align | "start" \| "center" \| "end" | "center" | Alignment relative to the trigger. |
| alignOffset | number | 0 | Offset along the alignment axis in pixels. |
| collisionPadding | number \| Partial<Record<Side, number>> | 5 | Padding from viewport edges for collision detection. |
| loopFocus | boolean | true | Wrap keyboard focus from last item back to first (and vice versa). |
| highlightItemOnHover | boolean | true | Highlight items on pointer hover. Set to false to differentiate CSS :hover from keyboard data-highlighted. |
| orientation | "vertical" \| "horizontal" | "vertical" | Arrow key direction for navigation. |
| onOpenChange | (open: boolean) => void | — | Called when the menu opens or closes. |
| onOpenChangeComplete | (open: boolean) => void | — | Called after open/close animation completes. |
Trigger
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| render | ReactElement \| (props) => ReactElement | — | Replace the default <button> with a custom element. See Custom trigger element. |
| nativeButton | boolean | auto | Whether the rendered element is a native <button>. Auto-detected from render; set explicitly when your custom component renders a <button> internally. |
| tooltip | ReactNode | — | Tooltip content shown on hover/focus. Dismissed when the menu opens. Requires TooltipRenderer. |
| tooltipArrow | boolean | false | Show a directional arrow on the trigger tooltip. |
Architecture
The problem
The standard approach with a headless component library is to co-locate a Tooltip.Root (or Menu.Root, Popover.Root) with each trigger that needs one. That works fine at small scale, but has a real cost when the same UI pattern repeats across a page:
- A data table with 200 rows, each with a row-action menu, mounts 200 full Base UI hook trees — Floating UI, hover/focus management, ARIA state, a portal — even though only one menu is ever open at a time and the rest sit idle.
- Tooltip triggers are especially common. Icon buttons, truncated cells, status badges: each one mounting its own positioning engine, delay timers, and DOM portal adds up fast.
- Even when popups are closed, each mounted
Rootholds event listeners, context subscriptions, and Floating UI state in memory. The per-trigger overhead is small, but it multiplies.
The underlying observation is that these are mutually exclusive UI elements — only one tooltip, one menu, and one popover can meaningfully be open at once. Having per-trigger infrastructure is paying for N independent systems when only one is ever used at a time.
The solution
Tooltip, Menu, and Popover all use a global singleton pattern:
Triggers are O(n), renderers are O(1). Each trigger renders a plain
<button>with ARIA attributes — no Base UI hooks, no Floating UI, no portal, no positioning logic. The expensive parts (Floating UI, portals, popup DOM, animations) live in a single renderer mounted once at the app root. Adding 200 triggers to a page adds 200 buttons and zero additional popup infrastructure.One popup at a time. Only one tooltip, one menu, and one popover can be open simultaneously. The singleton renderer subscribes to a global store and re-anchors to whichever trigger activated it. Switching between triggers swaps the anchor and content — no mount/unmount of the popup tree.
Config is per-instance, rendering is shared. Each
Rootregisters its configuration (positioning, callbacks) in a lightweight in-memory registry. When a trigger activates, the renderer looks up the config by trigger id and applies it. The popup DOM, portal, and positioning logic are reused across all instances.Zero idle cost. When no popup is open, the renderer is dormant — no Floating UI computations, no scroll/resize listeners, no portal in the DOM. Cost scales with interactions, not with the number of triggers on the page.
Custom trigger element
Tooltip.Trigger, Menu.Trigger, and Popover.Trigger accept a render prop to replace the default <button> with a custom element.
Element form — pass a React element:
<Menu.Trigger render={<MyButton variant="ghost" />}>
Open menu
</Menu.Trigger>Function form — receive the full props object:
<Menu.Trigger render={(props) => <MyButton {...props} />}>
Open menu
</Menu.Trigger>Your custom component must forward ref to its underlying DOM element.
Styling
Each component applies default class names (slithy-tooltip-*, slithy-menu-*) that can be overridden by passing your own className. Default styles use CSS custom properties for theming:
/* Tooltip */
--slithy-tooltip-bg
--slithy-tooltip-color
--slithy-tooltip-font-size
--slithy-tooltip-padding
--slithy-tooltip-radius
--slithy-tooltip-max-width
/* Popover */
--slithy-popover-bg
--slithy-popover-color
--slithy-popover-font-size
--slithy-popover-padding
--slithy-popover-radius
--slithy-popover-max-width
--slithy-popover-shadow
--slithy-popover-border
--slithy-popover-description-color
/* Menu */
--slithy-menu-bg
--slithy-menu-color
--slithy-menu-font-size
--slithy-menu-padding
--slithy-menu-radius
--slithy-menu-min-width
--slithy-menu-shadow
--slithy-menu-border
--slithy-menu-item-padding
--slithy-menu-item-highlighted-bg
--slithy-menu-separator-color
--slithy-menu-group-label-padding
--slithy-menu-group-label-colorCustom animations
Base UI exposes data-open, data-starting-style, and data-ending-style attributes on popup elements, so you can drive enter/leave animations from open state. The default styles use CSS transitions with these attributes.
