Popover

Non-modal overlay primitive with shared open/close lifecycle. Click activation toggles from its anchor by default; trigger=manual retains anchoring, expanded state, Escape and light dismissal while the caller controls open, including when switching between anchor controls.

Trigger binding

The for property connects Popover to the Looma button that toggles it.

Sam has booked the quiet room. Frodo is bringing the maps.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-button/ui-button.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-popover/ui-popover.html"
>

<ui-button
  id="popover-trigger"
  variant="ghost"
>Office briefing</ui-button>
<ui-popover
  id="example-popover"
  for="popover-trigger"
>Sam has booked the quiet room. Frodo is bringing the maps.</ui-popover>
<script setup>
  import { UiButton, UiPopover } from "@threadlabs/looma";
</script>

<template>
  <UiButton
    id="popover-trigger"
    variant="ghost"
  >Office briefing</UiButton>
  <UiPopover
    id="example-popover"
    for="popover-trigger"
  >Sam has booked the quiet room. Frodo is bringing the maps.</UiPopover>
</template>
import { UiButton, UiPopover } from "@threadlabs/looma";

export default function Example() {
  return <>
    <UiButton
      id="popover-trigger"
      variant="ghost"
    >Office briefing</UiButton>
    <UiPopover
      id="example-popover"
      htmlFor="popover-trigger"
    >Sam has booked the quiet room. Frodo is bringing the maps.</UiPopover>
  </>;
}
/** @jsxImportSource preact */
import { UiButton, UiPopover } from "@threadlabs/looma";

export default function Example() {
  return <>
    <UiButton
      id="popover-trigger"
      variant="ghost"
    >Office briefing</UiButton>
    <UiPopover
      id="example-popover"
      htmlFor="popover-trigger"
    >Sam has booked the quiet room. Frodo is bringing the maps.</UiPopover>
  </>;
}
<script>
  import { UiButton, UiPopover } from "@threadlabs/looma";
</script>

<UiButton
  id="popover-trigger"
  variant="ghost"
>Office briefing</UiButton>
<UiPopover
  id="example-popover"
  for="popover-trigger"
>Sam has booked the quiet room. Frodo is bringing the maps.</UiPopover>
import { Component } from "@angular/core";
import { UiButton, UiPopover } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiButton, UiPopover],
  template: `
    <ng-template
      ui-button
      [variant]="'ghost'"
      [hnAttributes]="{ id: 'popover-trigger' }"
    >Office briefing</ng-template>
    <span
      ui-popover
      id="example-popover"
      [for]="'popover-trigger'"
    >Sam has booked the quiet room. Frodo is bringing the maps.</span>
  `,
})
export default class Example {}

placement="top-end"

Placement sets the preferred edge and alignment against the trigger; here the surface opens above, aligned to the trigger's end edge.

Popover heading

Popover content.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-button/ui-button.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-popover/ui-popover.html"
>

<ui-button
  id="details-trigger"
>Open popover</ui-button>
<ui-popover
  id="details-popover"
  for="details-trigger"
  placement="top-end"
>
  <strong>Popover heading</strong>
  <p>Popover content.</p>
</ui-popover>
<script setup>
  import { UiButton, UiPopover } from "@threadlabs/looma";
</script>

<template>
  <UiButton
    id="details-trigger"
  >Open popover</UiButton>
  <UiPopover
    id="details-popover"
    for="details-trigger"
    placement="top-end"
  >
    <strong>Popover heading</strong>
    <p>Popover content.</p>
  </UiPopover>
</template>
import { UiButton, UiPopover } from "@threadlabs/looma";

export default function Example() {
  return <>
    <UiButton
      id="details-trigger"
    >Open popover</UiButton>
    <UiPopover
      id="details-popover"
      htmlFor="details-trigger"
      placement="top-end"
    >
      <strong>Popover heading</strong>
      <p>Popover content.</p>
    </UiPopover>
  </>;
}
/** @jsxImportSource preact */
import { UiButton, UiPopover } from "@threadlabs/looma";

export default function Example() {
  return <>
    <UiButton
      id="details-trigger"
    >Open popover</UiButton>
    <UiPopover
      id="details-popover"
      htmlFor="details-trigger"
      placement="top-end"
    >
      <strong>Popover heading</strong>
      <p>Popover content.</p>
    </UiPopover>
  </>;
}
<script>
  import { UiButton, UiPopover } from "@threadlabs/looma";
</script>

<UiButton
  id="details-trigger"
>Open popover</UiButton>
<UiPopover
  id="details-popover"
  for="details-trigger"
  placement="top-end"
>
  <strong>Popover heading</strong>
  <p>Popover content.</p>
</UiPopover>
import { Component } from "@angular/core";
import { UiButton, UiPopover } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiButton, UiPopover],
  template: `
    <ng-template
      ui-button
      [hnAttributes]="{ id: 'details-trigger' }"
    >Open popover</ng-template>
    <span
      ui-popover
      id="details-popover"
      [for]="'details-trigger'"
      [placement]="'top-end'"
    >
      <strong>Popover heading</strong>
      <p>Popover content.</p>
    </span>
  `,
})
export default class Example {}

Side placement and offset

Use a side placement and a local offset when one popover needs more space from its trigger.

Details

This surface prefers the right side and flips when it cannot fit there.

Code

<link
  rel="component"
  href="@threadlabs/looma/components/ui-button/ui-button.html"
>
<link
  rel="component"
  href="@threadlabs/looma/components/ui-popover/ui-popover.html"
>

<ui-button
  id="side-popover-trigger"
>Open details</ui-button>
<ui-popover
  for="side-popover-trigger"
  placement="right-start"
  style="--ui-popover-offset: 0.75rem"
>
  <strong>Details</strong>
  <p>This surface prefers the right side and flips when it cannot fit there.</p>
</ui-popover>
<script setup>
  import { UiButton, UiPopover } from "@threadlabs/looma";
</script>

<template>
  <UiButton
    id="side-popover-trigger"
  >Open details</UiButton>
  <UiPopover
    for="side-popover-trigger"
    placement="right-start"
    style="--ui-popover-offset: 0.75rem"
  >
    <strong>Details</strong>
    <p>This surface prefers the right side and flips when it cannot fit there.</p>
  </UiPopover>
</template>
import { UiButton, UiPopover } from "@threadlabs/looma";

export default function Example() {
  return <>
    <UiButton
      id="side-popover-trigger"
    >Open details</UiButton>
    <UiPopover
      htmlFor="side-popover-trigger"
      placement="right-start"
      style={{"--ui-popover-offset":"0.75rem"}}
    >
      <strong>Details</strong>
      <p>This surface prefers the right side and flips when it cannot fit there.</p>
    </UiPopover>
  </>;
}
/** @jsxImportSource preact */
import { UiButton, UiPopover } from "@threadlabs/looma";

export default function Example() {
  return <>
    <UiButton
      id="side-popover-trigger"
    >Open details</UiButton>
    <UiPopover
      htmlFor="side-popover-trigger"
      placement="right-start"
      style={{"--ui-popover-offset":"0.75rem"}}
    >
      <strong>Details</strong>
      <p>This surface prefers the right side and flips when it cannot fit there.</p>
    </UiPopover>
  </>;
}
<script>
  import { UiButton, UiPopover } from "@threadlabs/looma";
</script>

<UiButton
  id="side-popover-trigger"
>Open details</UiButton>
<UiPopover
  for="side-popover-trigger"
  placement="right-start"
  style="--ui-popover-offset: 0.75rem"
>
  <strong>Details</strong>
  <p>This surface prefers the right side and flips when it cannot fit there.</p>
</UiPopover>
import { Component } from "@angular/core";
import { UiButton, UiPopover } from "@threadlabs/looma";

@Component({
  selector: "example-view",
  standalone: true,
  imports: [UiButton, UiPopover],
  template: `
    <ng-template
      ui-button
      [hnAttributes]="{ id: 'side-popover-trigger' }"
    >Open details</ng-template>
    <span
      ui-popover
      [for]="'side-popover-trigger'"
      [placement]="'right-start'"
      style="--ui-popover-offset: 0.75rem"
    >
      <strong>Details</strong>
      <p>This surface prefers the right side and flips when it cannot fit there.</p>
    </span>
  `,
})
export default class Example {}

API

Package: @threadlabs/looma · Native root: span

Attributes

NameDescriptionPropertyTypeDefaultAllowed values
bareDelegates the surface to its content, with no padding, border, background, shadow, or overflow clipping.barebooleanfalse—
forID of the control that toggles and anchors the popover.forstring—
openWhether the popover is open initially or when set externally.openbooleanfalse—
placementPositions the surface around its trigger. Start and end on top or bottom follow the writing direction.placementkeywordbottom-starttop, top-start, top-end, bottom, bottom-start, bottom-end, left, left-start, left-end, right, right-start, right-end
sizeOptional size preset. content keeps the existing intrinsic sizing; the other sizes constrain the surface for compact, medium, or large content. Shared surface separation adapts to popover purpose and rendered size.sizekeywordcontentcontent, sm, md, lg
triggerClick toggles the popover from its for control. Manual retains anchoring, aria-expanded, Escape and light dismissal while the caller controls open.triggerkeywordclickclick, manual

Framework props

NameTypeDefaultAllowed values
barebooleanfalse—
forstring—
openbooleanfalse—
placement"top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end" | "right" | "right-start" | "right-end"bottom-starttop, top-start, top-end, bottom, bottom-start, bottom-end, left, left-start, left-end, right, right-start, right-end
size"content" | "sm" | "md" | "lg"contentcontent, sm, md, lg
trigger"click" | "manual"clickclick, manual

Events

NameDetailDescription
closeobject
  • open: boolean (required)
  • reason: keyword (required)
  • trigger: keyword (required)
Emitted when the component transitions to a closed state.
openobject
  • open: boolean (required)
  • reason: keyword (required)
  • trigger: keyword (required)
Emitted when the component transitions to an open state.

Slots

NamePurpose
defaultDefault child content.

Design tokens

Set shared theme properties on a theme boundary. Component hooks apply only to their owning component.

Component tokens

TokenDeclared valuesFallback
--ui-popover-border—var(--ui-overlay-border, var(--ui-border-strong)) | color-mix(in oklab, var(--ui-overlay-border, var(--ui-border-strong)) calc((1 - var(--_ui-material-active)) * 100%), transparent)
--ui-popover-padding—var(--ui-space-3)
--ui-popover-radius—var(--ui-overlay-radius, var(--ui-radius-lg))
--ui-popover-shadow—var(--ui-overlay-shadow, var(--ui-elevation-overlay, var(--ui-shadow-lg))) | var( --ui-overlay-shadow, 0 calc(var(--_ui-material-separation) * var(--_ui-material-scale, 0.55) * 8px) calc( var(--_ui-material-separation) * var(--_ui-material-scale, 0.55) * 24px ) color-mix(in oklab, var(--ui-text-primary) 15%, transparent), var(--ui-material-glow, 0 0 transparent), inset 0 0 0 calc(var(--_ui-material-active) * var(--ui-border-width)) var(--ui-material-edge, var(--ui-border-strong)), var(--ui-elevation-overlay, var(--ui-shadow-lg)) )
--ui-popover-surface—var(--ui-overlay-surface, var(--ui-surface-elevated)) | var(--_ui-material-underpaint, var(--ui-material-content, var(--ui-overlay-surface, var(--ui-surface-elevated))))
--ui-popover-viewport-gap—var(--ui-space-2)

Shared tokens

TokenFallback
--ui-border-strong—
--ui-border-width—
--ui-elevation-overlayvar(--ui-shadow-lg)
--ui-glassiness0
--ui-material-contentvar(--ui-overlay-surface, var(--ui-surface-elevated))
--ui-material-edgevar(--ui-border-strong)
--ui-material-glassvar(--ui-surface-elevated)
--ui-material-glow0 0 transparent
--ui-material-highlightvar(--ui-surface-highlight, linear-gradient(transparent, transparent))
--ui-motion-ease—
--ui-motion-fast—
--ui-overlay-bordervar(--ui-border-strong)
--ui-overlay-radiusvar(--ui-radius-lg)
--ui-overlay-shadowvar(--ui-elevation-overlay, var(--ui-shadow-lg)) | 0 calc(var(--_ui-material-separation) * var(--_ui-material-scale, 0.55) * 8px) calc( var(--_ui-material-separation) * var(--_ui-material-scale, 0.55) * 24px ) color-mix(in oklab, var(--ui-text-primary) 15%, transparent), var(--ui-material-glow, 0 0 transparent), inset 0 0 0 calc(var(--_ui-material-active) * var(--ui-border-width)) var(--ui-material-edge, var(--ui-border-strong)), var(--ui-elevation-overlay, var(--ui-shadow-lg))
--ui-overlay-surfacevar(--ui-surface-elevated)
--ui-radius-lg—
--ui-shadow-lg—
--ui-space-2—
--ui-space-3—
--ui-surface-elevated—
--ui-surface-highlightlinear-gradient(transparent, transparent)
--ui-surface-separation0
--ui-text-primary—