@pyreon/elements
v0.51.0
Published
Foundational UI components for Pyreon
Readme
@pyreon/elements
Five foundational layout primitives — Element, Text, List, Overlay, Portal — plus an Iterator helper.
@pyreon/elements is the layer between @pyreon/styler/@pyreon/unistyle and the high-level UI components. Every layout prop is responsive (single value, mobile-first array, or breakpoint object). Element is a three-section flex container (beforeContent / content / afterContent) with an internal fast path that collapses one wrapper layer when only content is present — measured 31-45% faster across mount benchmarks. Overlay ships a full useOverlay hook handling open/close, viewport flipping, ESC, click-outside, scroll tracking, hover delay, and modal overflow-locking — no positioning logic to reinvent. Iterator and List cover data-driven children with positional metadata; Portal renders into an isolated wrapper inside a configurable DOM location.
Install
bun add @pyreon/elements @pyreon/core @pyreon/reactivity @pyreon/ui-core @pyreon/unistyleQuick start
import { Element, Text, List, Overlay, Portal, Provider } from '@pyreon/elements'
<Provider>
<Element
tag="button"
direction="inline"
alignX="center"
alignY="center"
gap={8}
beforeContent={<Icon name="star" />}
afterContent={<Icon name="chevron-right" />}
>
Click me
</Element>
</Provider>Provider is re-exported from @pyreon/unistyle — set it once near the app root to scope breakpoints, root-size, and theme defaults.
Element — three-section flex layout
Most-used primitive. Renders an outer container with optional beforeContent / afterContent slots flanking the main content (children).
<Element
tag="button"
direction="inline" // 'inline' | 'rows' | 'reverseInline' | 'reverseRows'
alignX="center" // 'left' | 'center' | 'right' | 'spaceBetween' | ...
alignY="center" // 'top' | 'center' | 'bottom' | 'stretch' | ...
gap={8}
block // flex vs inline-flex
equalCols // equalize before/after widths via ResizeObserver
equalBeforeAfter
beforeContent={<Icon />}
afterContent={<Icon />}
>
Action
</Element>Content slots (priority: children > content > label):
| Prop | Type | Notes |
|---|---|---|
| children | VNodeChild | Standard JSX children |
| content | VNodeChild | Alternative slot when children is awkward |
| label | VNodeChild | Third fallback; useful in data-driven List |
| beforeContent | VNodeChild | Rendered before the main slot |
| afterContent | VNodeChild | Rendered after the main slot |
Layout props (all responsive):
| Prop | Default | Description |
|---|---|---|
| tag | 'div' | Outer HTML tag |
| direction | 'inline' | 'inline' (row) / 'rows' (column) / 'reverseInline' / 'reverseRows' |
| alignX | 'left' | Horizontal alignment along the flex direction |
| alignY | 'center' | Cross-axis alignment |
| gap | — | Gap between sections |
| block | — | flex vs inline-flex |
| equalCols | — | Equal width for before/after columns (snapshot at mount) |
| equalBeforeAfter | — | Equalize before/after via live ResizeObserver (resilient to async font/content changes) |
| dangerouslySetInnerHTML | — | Forwards to runtime-dom / runtime-server |
Per-section overrides: contentDirection, contentAlignX, beforeContentAlignY, afterContentDirection, etc. — every section accepts the same axis props prefixed with the section name.
Simple-path fast path: when there's no beforeContent / afterContent and the tag doesn't need the button/fieldset/legend two-layer flex fix, Element inlines the wrapper helper into ONE styled invocation. Saves one component invocation + one splitProps + one mountChild per Element. Real-Chromium benchmark drops a 500-child mount from 2.9ms to 1.6ms (-45%).
Text — semantic typography
<Text tag="h1">Heading</Text>
<Text paragraph>This renders as a <p>.</Text>
<Text tag="strong">Bold</Text>| Prop | Type | Notes |
|---|---|---|
| tag | 'h1'-'h6' / 'p' / 'span' / 'strong' / 'em' / 'small' / … | Inline-by-default |
| paragraph | boolean | Shorthand for tag="p" |
| children / label | VNodeChild | Text content |
| css | ExtendCss | Extend styling |
List — data-driven children with positional metadata
<List
component={ListItem}
data={[
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]}
itemKey="id"
itemProps={(item, { first, last, odd, even, index }) => ({
highlighted: first,
separator: !last,
})}
/>
// With root Element wrapper — gap/direction/align take effect
<List
rootElement
direction="rows"
gap={8}
component={Card}
data={items}
/>| Prop | Type | Notes |
|---|---|---|
| data | Array<string \| number \| object> | Source data |
| component | ComponentFn | Renders per item |
| valueName | string | Prop name for scalar values (default 'children') |
| itemKey | string \| (item) => Key | Key extractor |
| itemProps | object \| (item, meta) => object | Extra props injected per item |
| wrapComponent | ComponentFn | Wrapper around each item |
| rootElement | boolean | Wrap in an Element (enables direction / gap / align) |
Positional metadata ({ index, first, last, odd, even, position }) is passed to both itemKey and itemProps callbacks.
Iterator — lower-level data iterator
Same data/component model as List, four typed overloads (simple values, object values, children-only, loose forwarding) so spread-pattern wrapping (<Iterator {...wrapperProps} />) typechecks. Use when you don't need List's auto-wrap layout — e.g. emitting a flat array of <option> elements inside a <select>.
Overlay + useOverlay — dropdowns / tooltips / popovers / modals
Overlay takes a trigger and a content (children) render prop — each receives a
ref it MUST attach to its root node (that's how the hook measures, positions,
wires click-outside, and restores focus):
<Overlay
openOn="click"
closeOn="clickOutsideContent"
type="dropdown"
align="bottom"
alignX="left"
offsetY={4}
closeOnEsc
trigger={(t) => <button ref={t.ref}>Open menu</button>}
>
{(c) => (
<ul ref={c.ref}>
<li>Profile</li>
<li>Sign out</li>
</ul>
)}
</Overlay>The content also receives the resolved align / alignX / alignY as LIVE
reactive props, so a viewport-edge flip re-styles it in place — the content
subtree is NOT remounted (any input the user is typing in a popover survives).
For headless control, use the hook directly:
const o = useOverlay({
openOn: 'click',
closeOn: 'clickOnTrigger',
type: 'tooltip',
align: 'top',
onOpen: () => track('tooltip-open'),
})
// attach o.triggerRef to the anchor, o.contentRef to the floating node;
// o.active() is the open-state signal; o.showContent() / o.hideContent()
// control it; o.setContentPosition() reflows when the content SIZE changes.Built-in behaviour:
- Viewport-edge flipping — automatically flips align when the content would overflow.
- Position-on-open — content is anchored to the trigger the moment it opens (no scroll/resize needed).
- Throttled positioning — scroll + resize listeners throttled (default
throttleDelay200ms). - ESC + click-outside — opt-in via
closeOnEsc/closeOn: 'clickOutsideContent'. - Hover delay + reachable content —
hoverDelaydebounces open/close foropenOn: 'hover', and the tooltip/dropdown STAYS OPEN while the pointer is over its content (the content-hover listeners re-bind as the content mounts). - Focus management — focus returns to the opener on close (all types);
type: 'modal'also focuses into the content on open and traps Tab / Shift+Tab (the WAI-ARIA dialog pattern). - Modal overflow lock —
type: 'modal'ref-countsdocument.bodyoverflow so nested modals don't double-lock.
OverlayProvider + useOverlayContext coordinate nested overlays (a child overlay blocks its parent from closing while open). A root <OverlayProvider> establishes the context with no-op defaults; its coordination props are optional.
Portal — render into a different DOM location
<Portal target={document.body} tag="div" data-modal-id="settings">
<Modal />
</Portal>Creates a per-instance wrapper element (default <div>, configurable via tag) INSIDE target (default document.body). Multiple portals share target without intermingling children — each gets its own wrapper.
| Prop | Default | Notes |
|---|---|---|
| target | document.body | Destination element (HTMLElement \| (() => HTMLElement) \| null) |
| tag | 'div' | Wrapper HTML tag |
| Any data-/aria- attrs | — | Forwarded to the wrapper |
Util — utility wrapper for non-layout primitives
Reserved escape-hatch for components that need styler integration without Element's layout props (e.g. SVG roots). Same theme/style pipeline, no axis/gap props.
Responsive values
direction="inline" // single value
direction={['rows', 'inline']} // mobile-first array
direction={{ xs: 'rows', md: 'inline', lg: 'inline' }} // breakpoint objectApplies to tag, direction, alignX, alignY, gap, block, equalCols, and every per-section variant.
Gotchas
- Wrapper drops the children slot for void tags (
<hr>,<input>,<img>,<br>, etc.) so{undefined}JSX slots don't trip runtime-dom's "void element cannot have children" warning. If you author a custom wrapper that forwardschildren, branch ongetShouldBeEmpty(tag)first. <Portal>creates a per-instance wrapper INSIDEtarget—document.body.firstChildis not your modal; query viadocument.body.querySelector('[data-modal-id]').parentElement.equalBeforeAfterusesResizeObserverand falls back to a one-shot measurement when the API is unavailable (SSR, older runtimes). For async content (font swaps, lazy images) you wantequalBeforeAfter, notequalCols.- Element's
directionaccepts'inline' | 'rows' | 'reverseInline' | 'reverseRows'—'row'is invalid (caught by TS). Iteratorships 4 overloads with aLoosePropsfallback so<Iterator {...wrapperProps} />forwarding patterns typecheck. The trade-off: mixed-shape arrays ([1, {id:1}, null]) bind to the fallback rather than failing at the type level. Runtime still picks the right mode based on which props are populated.
Documentation
Full docs: pyreon.dev/docs/elements (or docs/src/content/docs/elements.md in this repo).
License
MIT
