Skip to main content
On this pageOverview

Anchor

Overview

Anchor is the positioning runtime the floating components are built on. Listbox, Combobox, Menu, Popover, and Tooltip hand their anchor prop straight to it, and Date Picker forwards its own through Popover. It wraps Floating UI, adding the portaling, focus choreography, and styling hooks those components share.

It is exported so you can build an anchored component Foldkit does not ship. If one of the six above fits, use it. Reach for this module when the panel you need differs structurally from all of them, for example a virtualized list with group headers, or a multi-select that stages changes against an open-time baseline and commits them with a Done action.

import {
  AnchorConfig,
  Padding,
  Placement,
  anchorSetup,
  portalToContainingRoot,
} from '@foldkit/ui/anchor'
import type { SetupConfig } from '@foldkit/ui/anchor'

The module is also exported from the root barrel, as import { Anchor } from '@foldkit/ui'.

Positioning a Panel

anchorSetup is a plain DOM function. It takes the element and a config, and returns a cleanup. An element exists in the rendered tree and the factory uses that element to do DOM work, so Mount is the primitive that owns it. Mount.define covers the one-shot acquire-with-cleanup shape.

// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt. Fit them into your own Model, Message, update,
// and view definitions.
import { Effect, Schema as S } from 'effect'
import { Mount } from 'foldkit'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { m } from 'foldkit/message'

import { AnchorConfig, anchorSetup } from '@foldkit/ui/anchor'

// Every Mount Definition declares at least one result Message. Name it after
// the Definition, the way a Command's result Message is named after the
// Command:
const CompletedAnchorPanel = m('CompletedAnchorPanel')

// Mount.define takes the Definition name, a Schema for the args captured at
// mount, and the result Message. anchorSetup is a plain DOM function that
// returns a cleanup, so it goes inside Effect.sync and the cleanup is
// registered with Effect.acquireRelease. Construct the resource inside the
// acquire body, never before it, or it leaks on interruption:
const AnchorPanel = Mount.define(
  'AnchorPanel',
  { buttonId: S.String, anchor: AnchorConfig },
  CompletedAnchorPanel,
)(
  ({ buttonId, anchor }) =>
    element =>
      Effect.gen(function* () {
        yield* Effect.acquireRelease(
          Effect.sync(() => anchorSetup(element, { buttonId, anchor })),
          cleanup => Effect.sync(cleanup),
        )
        return CompletedAnchorPanel()
      }),
)

// The trigger needs a stable id, because that is what anchorSetup resolves
// the button by. Render the panel only while it is open, and spread the Mount
// onto it. The panel starts at visibility: hidden so it cannot flash at the
// top left corner before Floating UI resolves its first position; anchorSetup
// clears that once the panel is placed:
const view = (h: HtmlBuilder<Message>): Html =>
  h.div(
    [],
    [
      h.button(
        [h.Id('search-select-button'), h.OnClick(ClickedTrigger())],
        ['Open'],
      ),
      ...(model.isOpen
        ? [
            h.div(
              [
                h.Style({
                  position: 'absolute',
                  margin: '0',
                  visibility: 'hidden',
                }),
                h.Class('z-10 rounded-lg border bg-white shadow-lg'),
                h.OnMount(
                  AnchorPanel({
                    buttonId: 'search-select-button',
                    anchor: { placement: 'bottom-start', gap: 4 },
                  }),
                ),
              ],
              [
                // ...your own panel content
              ],
            ),
          ]
        : []),
    ],
  )

// Mount args are captured at mount, not refreshed across renders. When the
// config has to change, unmount and remount the panel rather than expecting
// a new `anchor` value to reach the running Mount.

Two details decide whether the panel behaves:

The trigger needs a stable id. anchorSetup resolves the button by buttonId through the element's own root, so a panel rendered inside a shadow root finds a trigger in that same shadow root. A document-scoped lookup would not. When the lookup misses, anchorSetup returns a cleanup that does nothing and never positions anything, which leaves the panel hidden with no error reported. A panel that mounts but never appears is the symptom of a buttonId that does not match the trigger.

The panel must start hidden. anchorSetup clears visibility after the first position resolves, which means the element is expected to render at visibility: hidden. Skip it and the panel paints at the top left corner for a frame before Floating UI places it.

Mount args are captured at mount, not refreshed across renders. A changed anchor value does not reach a running Mount, so unmount and remount the panel when the config has to change.

Portaling

anchorSetup portals the panel itself. AnchorConfig.portal defaults to true, which relocates the element into a shared foldkit-portal-root div so it escapes any ancestor stacking context or overflow: hidden. Pass portal: false to leave the panel where it was rendered.

portalToContainingRoot is the same relocation as a standalone function, for the elements anchorSetup does not touch. A modal backdrop is the usual case: Popover portals its backdrop through a second Mount for exactly this reason. Give it its own Mount.define, since one element takes one Mount.

The containing root is the shadow root when the app is mounted inside one, and document.body otherwise, so a portaled panel keeps that root's scoped styles. The portal root div is prepended rather than appended, which keeps component wrappers painting above a backdrop and leaves click-outside detection working.

What Anchor Writes to Your Element

Anchor writes the following while the Mount is alive. Except where a row says otherwise, whatever you set is overwritten. Only data-placement is cleaned up on unmount; the inline styles are left on the element, which is normally invisible because the element unmounts with the Mount.

PropertyDescription
left / topThe resolved position, recomputed on scroll, resize, and layout shift.
visibilityCleared once the first position resolves. Render the element at visibility: hidden.
max-heightThe height available in the viewport, so a long panel scrolls instead of overflowing.
overflow-y / overscroll-behaviorSet to auto and none, so a scrolled panel does not chain its scroll to the page.
--button-widthThe trigger's width as a custom property. Use it to match panel width to trigger width in CSS.
positionSet to fixed only inside a shadow root, where the absolute strategy mis-measures against the light-DOM host. Otherwise the element keeps the position: absolute you gave it.
data-placementThe side the panel currently sits on: top, right, bottom, or left. Removed on cleanup.

data-placement is the hook for styling by side, for example an arrow that flips with the panel. With isPlacementLocked: true it holds the side the first positioning picked and stays there.

Reach for a Component First

Building on Anchor means owning the parts the components already handle: open and closed state, click-outside dismissal, Escape handling, focus return, ARIA wiring, and enter and leave animation. Anchor covers positioning and portaling, and nothing above them.

Popover is the closest thing to a blank panel Foldkit ships. It takes arbitrary content, handles dismissal and focus, and leaves the inside to you. Check that it does not fit before writing your own.

API Reference

anchorSetup

(element: Element, config: SetupConfig) => () => void

Positions an element against the trigger named by config.buttonId, portals it unless anchor.portal is false, and returns a cleanup. Call it inside Effect.sync in a Mount and register the cleanup with Effect.acquireRelease.

AnchorConfig

Static positioning options. Every field is optional. This is the same Schema the six components accept as their anchor prop, and it is available as a value here rather than as a type alone.

NameTypeDefaultDescription
placementPlacement'bottom-start'Preferred side and alignment. Floating UI flips to the opposite side when the preferred one does not fit, unless isPlacementLocked is set.
gapnumber0Distance in pixels between the trigger and the panel, along the placement axis.
offsetnumber0Shift in pixels along the cross axis, for nudging the panel sideways from its alignment.
paddingPadding0Minimum distance to the viewport edge, honored by shift, the height calculation, and flip while placement is unlocked. A number applies to all sides; an object sets them per side.
portalbooleantrueRelocates the panel into the shared portal root so it escapes ancestor clipping and stacking contexts.
isPlacementLockedbooleanfalseKeeps the side the first positioning resolves, dropping flip from every later update. Use it when a panel that flips mid-interaction reads as a jump.

SetupConfig

The second argument to anchorSetup.

NameTypeDefaultDescription
buttonIdstringId of the trigger to position against. Resolved through the element's own root, so it works inside a shadow root. Required.
anchorAnchorConfigThe static positioning options above. Required.
interceptTabbooleantrueReturns focus to the trigger when Tab is pressed inside a portaled panel. Set it to false when the panel holds focusable content Tab should move through.
focusAfterPositionbooleanfalseFocuses the panel once the first position resolves, deferred a frame so the element is painted first.
focusSelectorstringFocuses a descendant matching this selector instead of the panel itself, for example a grid inside a panel. Only read when focusAfterPosition is true.

portalToContainingRoot

(element: Element) => () => void

Relocates an element into the shared foldkit-portal-root div within its containing root and returns a cleanup that removes it. Use it for elements outside the anchored panel, since anchorSetup already portals the panel unless portal: false says otherwise.

Placement and Padding

Placement is a Schema over the twelve Floating UI placements: a side (top, right, bottom, left), optionally suffixed with -start or -end.

Padding is a Schema over a uniform number or a partial per-side object with top, right, bottom, and left. Both are exported so an AnchorConfig can be described from this subpath alone.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson