Anchored Positioning
stimeo--anchored
Keeps a floating element beside its anchor, turning it around when it meets a screen edge.
Keeps a floating element such as a popover or a hint beside the element it belongs to. It follows as the page scrolls and as the window resizes, and when it meets a screen edge it swings to the other side or slides along to stay in view. It writes only the position and never touches your decoration. Which side it ended up on is readable from CSS, so a bubble's arrow can follow along. While the floating element is hidden you can stop the tracking, so it stops measuring while there is nothing to see. It does not open, close or manage focus, so pair it with a popover or a dialog. The placement maths lives in a module you opt into, so it adds nothing to your bundle until you use it.
The panel is placed against the reference element. Choose a side and the panel shows where it actually ended up, which can change near a screen edge.
Keyboard
This component has no keyboard interactions of its own.
<%# Anchored positioning demo: the panel is positioned against the anchor button by the
opt-in stimeo--anchored controller (it writes only position/left/top). Choose a side
with the buttons — demo.js sets the placement value — and the panel mirrors the
resolved side via data-anchored-placement (shown through demo.css). The controller is
registered from the opt-in stimeo-ui/positioning subpath in demo.js; demo.css owns the
look. %>
<div class="anchored-demo">
<p class="anchored-demo__hint"><%= t("components.anchored.demo.hint") %></p>
<div class="anchored-demo__controls" role="group"
aria-label="<%= t('components.anchored.demo.placement_label') %>">
<button type="button" class="demo-trigger" data-placement="top" aria-pressed="false">
<%= t("components.anchored.demo.placements.top") %>
</button>
<button type="button" class="demo-trigger" data-placement="right" aria-pressed="false">
<%= t("components.anchored.demo.placements.right") %>
</button>
<button type="button" class="demo-trigger" data-placement="bottom" aria-pressed="true">
<%= t("components.anchored.demo.placements.bottom") %>
</button>
<button type="button" class="demo-trigger" data-placement="left" aria-pressed="false">
<%= t("components.anchored.demo.placements.left") %>
</button>
</div>
<div class="anchored-demo__viewport">
<div class="anchored-demo__scope" data-controller="stimeo--anchored"
data-stimeo--anchored-placement-value="bottom"
data-stimeo--anchored-offset-value="8"
data-stimeo--anchored-padding-value="8">
<button type="button" class="demo-trigger anchored-demo__anchor"
data-stimeo--anchored-target="anchor">
<%= t("components.anchored.demo.anchor") %>
</button>
<div class="anchored-demo__floating" data-stimeo--anchored-target="floating"
data-placement-label="<%= t('components.anchored.demo.placement_prefix') %>"></div>
</div>
</div>
</div>
/*
* Presentation-only styles for the anchored-positioning demo. The library writes only
* position/left/top on the floating element and mirrors the resolved side onto
* data-anchored-placement; this CSS frames the viewport (a positioned ancestor the
* absolute coordinates resolve against), the anchor, and the floating panel — and shows
* the resolved placement through the data-* hook.
*/
.anchored-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
width: 100%;
}
.anchored-demo__hint {
margin: 0;
color: var(--muted);
}
.anchored-demo__controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
/* Positioned ancestor: the engine's absolute coordinates resolve against this box. */
.anchored-demo__viewport {
position: relative;
display: grid;
place-items: center;
width: 100%;
min-height: 16rem;
border: 1px dashed var(--border);
border-radius: 0.5rem;
}
/* The scope is layout-transparent so the anchor centers in the viewport grid and the
floating element positions against the viewport, not an extra box. */
.anchored-demo__scope {
display: contents;
}
.anchored-demo__floating {
/* The engine sets position/left/top; everything here is look-only and themable.
Start absolute so the panel never disrupts layout before the controller connects. */
position: absolute;
top: 0;
left: 0;
min-width: 7rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--accent);
border-radius: 0.375rem;
background: var(--bg);
color: var(--fg);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
/* Surface the resolved (post-flip) placement from the controller's data-* hook. The
label comes from a data-* attribute so it follows the page locale; only the
placement value itself is API vocabulary and stays as the engine resolved it. */
.anchored-demo__floating::after {
content: attr(data-placement-label) " " attr(data-anchored-placement);
color: var(--muted);
}
// anchored opt-in positioning demo (consumer-side JS).
//
// stimeo--anchored ships in the opt-in stimeo-ui/positioning subpath, so it is NOT
// auto-registered by registerStimeo (which keeps the core install zero-dependency).
// Importing the subpath here is what pulls in @floating-ui/dom; we register the
// controller on the playground's Stimulus app, then wire the placement buttons to the
// controller's placement value so picking a side re-positions the panel.
import { AnchoredController } from "stimeo-ui/positioning";
// Register once (Turbo can re-run this inline module on navigation).
if (window.Stimulus && !window.__stimeoAnchoredRegistered) {
window.Stimulus.register("stimeo--anchored", AnchoredController);
window.__stimeoAnchoredRegistered = true;
}
document.querySelectorAll(".anchored-demo").forEach((root) => {
// Idempotent: wire each root once even if this module re-runs. The marker is a property,
// not an attribute: Turbo copies attributes into its page snapshot, so an attribute one
// comes back set on a restored page whose elements carry no listeners.
if (root.demoWired) return;
root.demoWired = true;
const scope = root.querySelector('[data-controller~="stimeo--anchored"]');
if (!scope) return;
const buttons = root.querySelectorAll("[data-placement]");
buttons.forEach((button) => {
button.addEventListener("click", () => {
// Setting the value triggers placementValueChanged → the controller re-positions.
scope.setAttribute("data-stimeo--anchored-placement-value", button.dataset.placement);
buttons.forEach((other) => other.setAttribute("aria-pressed", String(other === button)));
});
});
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--anchored"
Targets
| Name | Description | Attribute |
|---|---|---|
anchor
required
|
The reference element the floating element is positioned against. | data-stimeo--anchored-target="anchor" |
floating
required
|
The element that is positioned (only its coordinates are written). | data-stimeo--anchored-target="floating" |
Values
| Name | Description | Attribute |
|---|---|---|
placement
|
Preferred side (floating-ui Placement, e.g. top-start); default bottom. |
data-stimeo--anchored-placement-value |
offset
|
Gap in px between the anchor and the floating element; default 0. | data-stimeo--anchored-offset-value |
flip
|
Flip to the opposite side when the preferred side would overflow; default true. | data-stimeo--anchored-flip-value |
shift
|
Shift along the axis to keep it in view; default true. | data-stimeo--anchored-shift-value |
padding
|
Padding (px) kept from the viewport edge when flipping/shifting; default 0. | data-stimeo--anchored-padding-value |
strategy
|
CSS positioning strategy, absolute or fixed; default absolute. |
data-stimeo--anchored-strategy-value |
active
|
Whether tracking is on; set false while hidden to stop measuring; default true. | data-stimeo--anchored-active-value |
Events
| Name | Description | Event |
|---|---|---|
position
|
Fires with { placement, x, y } each time the position is computed. | stimeo--anchored:position |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-anchored-placement |
Floating target | The resolved placement after flip/shift (e.g. top-start); a CSS hook for arrows. |
position / left / top |
Floating target | Inline styles written on each update; no decoration is ever set. |