A positioning engine for floating UI elements. Built to solve real problems with tooltips, dropdowns, and popovers — without the magic.
Positioning floating elements sounds simple until you hit:
overflow: hiddenclipping your dropdown- z-index wars across stacking contexts
- Tooltips getting cut off at viewport edges
- Initial flicker before position is calculated
- Mysterious jumps when content resizes
Existing solutions (Popper.js, Floating UI) work, but they're often opaque. When something breaks, good luck debugging.
Layercraft takes a different approach: explicit math, predictable behavior, no magic.
- 🎯 12 placement options — top, bottom, left, right with start/center/end alignment
- 🔄 Auto-flip — automatically flips when there's not enough space
- 🔀 Shift — keeps element within viewport without changing placement
- 📍 Fallback placements — custom flip order for complex layouts
- 🚀 Portal support — escape
overflow: hiddencontainers - ➡️ Arrow positioning — built-in arrow/caret support
- ⌨️ Keyboard support — Escape key to close
- 🖱️ Multiple triggers — click, hover
- 📏 Auto-update — responds to scroll, resize, and content changes
- ✨ Flicker-free —
isReadyflag prevents initial position jump - 🪶 Lightweight — ~5KB minified + gzipped
- 📦 Tree-shakeable — only import what you use
- 🔷 TypeScript — fully typed API
npm install layercraftimport { useAnchor } from 'layercraft';
function Tooltip() {
const { refCallbacks, floatingStyles, isOpen, isReady } = useAnchor({
placement: 'top',
trigger: 'hover',
});
return (
<>
<button ref={refCallbacks.anchor}>Hover me</button>
{isOpen && (
<div
ref={refCallbacks.floating}
style={{
...floatingStyles,
visibility: isReady ? 'visible' : 'hidden', // Prevents flicker
}}
>
Tooltip content
</div>
)}
</>
);
} top-start top top-end
┌───────────────────────┐
│ │
left-start │ │ right-start
│ │
left │ ANCHOR │ right
│ │
left-end │ │ right-end
│ │
└───────────────────────┘
bottom-start bottom bottom-end
function Dropdown() {
const { refCallbacks, floatingStyles, isOpen } = useAnchor({
placement: 'bottom-start',
trigger: 'click',
closeOnOutsideClick: true,
closeOnEscape: true,
});
return (
<>
<button ref={refCallbacks.anchor}>Open Menu</button>
{isOpen && (
<ul ref={refCallbacks.floating} style={floatingStyles}>
<li>Option 1</li>
<li>Option 2</li>
<li>Option 3</li>
</ul>
)}
</>
);
}function Tooltip() {
const { refCallbacks, floatingStyles, isOpen, isReady } = useAnchor({
placement: 'top',
trigger: 'hover',
offset: 6,
});
return (
<>
<span ref={refCallbacks.anchor}>Hover for info</span>
{isOpen && (
<div
ref={refCallbacks.floating}
style={{
...floatingStyles,
visibility: isReady ? 'visible' : 'hidden',
background: '#333',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '14px',
}}
>
Helpful tooltip text
</div>
)}
</>
);
}With Portal (Escape overflow: hidden)
Use Portal when your anchor is inside an overflow: hidden container:
import { useAnchor, Portal } from 'layercraft';
function PopoverWithPortal() {
const { refCallbacks, floatingStyles, isOpen } = useAnchor({
placement: 'bottom',
trigger: 'click',
});
return (
<div style={{ overflow: 'hidden', position: 'relative' }}>
<button ref={refCallbacks.anchor}>Click me</button>
{isOpen && (
<Portal>
<div ref={refCallbacks.floating} style={floatingStyles}>
I escape the overflow container!
</div>
</Portal>
)}
</div>
);
}When to use Portal:
- Anchor is inside
overflow: hiddenoroverflow: auto - Anchor is inside a scrollable container
- Floating element gets clipped by parent boundaries
function TooltipWithArrow() {
const { refCallbacks, floatingStyles, arrowStyles, isOpen, isReady } = useAnchor({
placement: 'top',
trigger: 'hover',
arrow: { size: 8 },
});
return (
<>
<button ref={refCallbacks.anchor}>Hover me</button>
{isOpen && (
<div
ref={refCallbacks.floating}
style={{
...floatingStyles,
visibility: isReady ? 'visible' : 'hidden',
background: '#333',
color: 'white',
padding: '8px 12px',
borderRadius: '4px',
}}
>
Tooltip with arrow
<div
ref={refCallbacks.arrow}
style={{
...arrowStyles,
position: 'absolute',
width: 8,
height: 8,
background: '#333',
transform: `${arrowStyles.transform} rotate(45deg)`,
}}
/>
</div>
)}
</>
);
}Control the flip order when preferred placement doesn't fit:
const { actualPlacement } = useAnchor({
placement: 'right',
fallbackPlacements: ['left', 'bottom', 'top'], // Try these in order
});
// actualPlacement tells you which placement was actually used
console.log(`Using: ${actualPlacement}`);Shift keeps the floating element within viewport bounds without changing placement:
const { refCallbacks, floatingStyles, isOpen } = useAnchor({
placement: 'right',
shift: true, // Enabled by default
shiftPadding: 8, // Padding from viewport edge
});Without shift: With shift:
┌─────────────────┐ ┌─────────────────┐
│ ┌──────── │ ┌──────┐│
│ Anchor │Tooltip│ │ Anchor │Tooltip│
│ └──────── │ └──────┘│
└─────────────────┘ └─────────────────┘
(clips at edge) (shifted to fit)
To disable shift:
const { ... } = useAnchor({
placement: 'top',
shift: false, // Disable shifting
});Manage open state yourself:
function ControlledPopover() {
const [isOpen, setIsOpen] = useState(false);
const { refCallbacks, floatingStyles } = useAnchor({
placement: 'bottom',
trigger: 'click',
});
return (
<>
<button
ref={refCallbacks.anchor}
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? 'Close' : 'Open'}
</button>
{isOpen && (
<div ref={refCallbacks.floating} style={floatingStyles}>
Controlled content
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</>
);
}Use the core functions without React:
import { getPosition, getPositionWithFlip, shiftToViewport, getViewport } from 'layercraft';
const anchor = document.querySelector('#anchor');
const floating = document.querySelector('#floating');
const anchorRect = anchor.getBoundingClientRect();
const floatingRect = floating.getBoundingClientRect();
const viewport = getViewport();
// Step 1: Calculate position with auto-flip
const result = getPositionWithFlip(
anchorRect,
floatingRect,
{ placement: 'bottom', offset: 8 },
viewport
);
// Step 2: Apply shift to keep within viewport
const shifted = shiftToViewport(
result.top,
result.left,
floatingRect.width,
floatingRect.height,
viewport,
8 // padding
);
// Step 3: Apply styles
floating.style.position = 'fixed';
floating.style.top = `${shifted.top}px`;
floating.style.left = `${shifted.left}px`;| Option | Type | Default | Description |
|---|---|---|---|
placement |
Placement |
'bottom' |
Where to position the floating element |
offset |
number |
8 |
Gap between anchor and floating element (px) |
trigger |
'click' | 'hover' |
'click' |
How to open the floating element |
closeOnOutsideClick |
boolean |
true |
Close when clicking outside |
closeOnEscape |
boolean |
true |
Close when pressing Escape |
autoFlip |
boolean |
true |
Flip to opposite side if no space |
fallbackPlacements |
Placement[] |
undefined |
Custom fallback placement order |
shift |
boolean |
true |
Keep floating element within viewport by shifting |
shiftPadding |
number |
8 |
Padding from viewport edge when shifting (px) |
arrow |
boolean | { size: number } |
false |
Enable arrow positioning |
zIndex |
number |
9999 |
z-index for floating element |
| Property | Type | Description |
|---|---|---|
isOpen |
boolean |
Current open state |
setIsOpen |
function |
Manually control open state |
isReady |
boolean |
true after position is calculated (use for visibility) |
floatingStyles |
CSSProperties |
Apply to floating element |
arrowStyles |
CSSProperties |
Apply to arrow element |
actualPlacement |
Placement |
The placement used (may differ if flipped) |
refCallbacks.anchor |
function |
Attach to anchor element |
refCallbacks.floating |
function |
Attach to floating element |
refCallbacks.arrow |
function |
Attach to arrow element |
Renders children into document.body (or custom container) to escape overflow containers.
import { Portal } from 'layercraft';
// Default: renders to document.body
<Portal>
<div>Rendered at document.body</div>
</Portal>
// Custom container
<Portal container={document.getElementById('portal-root')}>
<div>Rendered in #portal-root</div>
</Portal>For vanilla JS or custom implementations:
import {
getPosition, // Basic positioning
getPositionWithFlip, // Positioning with auto-flip
shiftToViewport, // Keep within viewport by shifting
getArrowPosition, // Arrow positioning
clampToViewport, // Clamp to viewport bounds
isOutOfBounds, // Check if position overflows
getViewport, // Get viewport dimensions
} from 'layercraft';src/
├── core/ # Pure positioning math, framework-agnostic
│ ├── getPosition.ts
│ ├── flip.ts
│ └── viewport.ts
└── react/ # React adapter
├── useAnchor.ts
└── Portal.tsx
The core is framework-agnostic. The React hook is a thin wrapper. You could build Vue, Svelte, or vanilla adapters on the same core.
- No z-index coordination — Use the
zIndexoption to manually manage stacking order between multiple floating elements - No animations — Use CSS transitions or animation libraries
- React 17+ — Uses modern React features
# Install dependencies
npm install
# Start dev server
npm run dev
# Run tests
npm run test
# Build for production
npm run build
# Run Storybook
npm run storybookContributions are welcome! Please:
- Fork the repository
- Create a feature branch (
git checkout -b feat/amazing-feature) - Commit your changes (
git commit -m 'feat: add amazing feature') - Push to the branch (
git push origin feat/amazing-feature) - Open a Pull Request
MIT © Venkatesh M