Popover

Anchored disclosure panel. A relative root with a trigger and a panel child; the panel reveals on hover, on focus-within, or via two opt-in JS hooks. Four placements (top, bottom, left, right) reposition both the panel and its CSS arrow. Per-combination tinting paints the panel with the framework's shared colour map.

Live

Default (top placement, hover / focus-within reveal)

<span class="popover">
  <button class="popover__trigger" type="button">Help</button>
  <div class="popover__panel" role="tooltip">
    <div class="popover__header">Shortcut</div>
    <div class="popover__body">Press <kbd>?</kbd> to open the full reference.</div>
  </div>
</span>
@use '@adnap/krysalicss' with (
  $feature-list: ('base-reset', 'base-global', 'module-popover')
);
Playground

.is-bottom

Panel anchored to the trigger's block-end edge.
<span class="popover is-bottom">
  <button class="popover__trigger" type="button">Open below</button>
  <div class="popover__panel">
    <div class="popover__body">Panel anchored to the trigger's block-end edge.</div>
  </div>
</span>
@use '@adnap/krysalicss' with (
  $feature-list: ('base-reset', 'base-global', 'module-popover')
);
Playground

.is-right

Panel anchored to the trigger's inline-end edge.
<span class="popover is-right">
  <button class="popover__trigger" type="button">Open right</button>
  <div class="popover__panel">
    <div class="popover__body">Panel anchored to the trigger's inline-end edge.</div>
  </div>
</span>
@use '@adnap/krysalicss' with (
  $feature-list: ('base-reset', 'base-global', 'module-popover')
);
Playground

.is-active (JS class hook, panel pinned open)

Pinned open
Visible because the root carries .is-active.
<span class="popover is-active">
  <button class="popover__trigger" type="button" aria-expanded="true">Open (JS hook)</button>
  <div class="popover__panel">
    <div class="popover__header">Pinned open</div>
    <div class="popover__body">Visible because the root carries <code>.is-active</code>.</div>
  </div>
</span>
@use '@adnap/krysalicss' with (
  $feature-list: ('base-reset', 'base-global', 'module-popover')
);
Playground

[data-open='true'] (JS attribute hook, panel pinned open)

Visible because the root carries data-open="true".
<span class="popover" data-open="true">
  <button class="popover__trigger" type="button" aria-expanded="true">Open (data hook)</button>
  <div class="popover__panel">
    <div class="popover__body">Visible because the root carries <code>data-open="true"</code>.</div>
  </div>
</span>
@use '@adnap/krysalicss' with (
  $feature-list: ('base-reset', 'base-global', 'module-popover')
);
Playground

.is-primary combination tint

Panel paints in the primary combination.
<span class="popover is-primary">
  <button class="popover__trigger" type="button">Branded popover</button>
  <div class="popover__panel">
    <div class="popover__body">Panel paints in the <code>primary</code> combination.</div>
  </div>
</span>
@use '@adnap/krysalicss' with (
  $feature-list: ('base-reset', 'base-global', 'module-popover')
);
Playground

Markup

The root sets position: relative and display: inline-block so the panel's absolute positioning anchors to the trigger element. The panel starts at visibility: hidden; opacity: 0 and transitions both properties so screen readers still discover the panel during the fade-out (visibility flips at the end of the transition). The transition is removed entirely under prefers-reduced-motion: reduce.

markup
<span class="popover">
  <button class="popover__trigger" type="button">Help</button>
  <div class="popover__panel" role="tooltip">
    <div class="popover__header">Shortcut</div>
    <div class="popover__body">Press <kbd>?</kbd> to open the full reference.</div>
  </div>
</span>

Modes

The component ships four reveal modes. The CSS-only modes (:hover and :focus-within) are always emitted; the JS hooks (.is-active, [data-open="true"]) are gated behind $controls including 'js' — the framework-wide default includes both.

CSS-only mode (hover + focus-within)

Pointing at the root or moving keyboard focus into it reveals the panel. Same rationale as the Dropdown CSS-only reveal: covers pointer users with :hover and keyboard users with :focus-within. Zero JavaScript required, no state management.

JS-driven mode (class hook)

Add .is-active to the root from your script. Pair with aria-expanded on the trigger so screen readers announce the open state.

.is-active hook
<span class="popover is-active">
  <button class="popover__trigger" type="button" aria-expanded="true">Open (JS hook)</button>
  <div class="popover__panel">
    <div class="popover__header">Pinned open</div>
    <div class="popover__body">Visible because the root carries <code>.is-active</code>.</div>
  </div>
</span>

JS-driven mode (attribute hook)

Toggle data-open="true" on the root. Useful when an external state store drives DOM attributes (e.g. a Vue / React binding).

[data-open] hook
<span class="popover" data-open="true">
  <button class="popover__trigger" type="button" aria-expanded="true">Open (data hook)</button>
  <div class="popover__panel">
    <div class="popover__body">Visible because the root carries <code>data-open="true"</code>.</div>
  </div>
</span>

To ship only the CSS-driven branch and strip the JS hooks from the compiled output:

css-only build
@use '@adnap/krysalicss/module/popover' with (
$controls: ('css'),
);

To ship only the JS hooks:

js-only build
@use '@adnap/krysalicss/module/popover' with (
$controls: ('js'),
);

Reveal matrix

TriggerSelectorGatingNotes
Pointer hover.popover:hover .popover__panelAlways emitted.Mouse / touch hover. Pointer-only — no keyboard equivalent.
Keyboard focus.popover:focus-within .popover__panelAlways emitted.Tabbing to the trigger or anything inside the panel keeps it open.
JS class hook.popover.is-active .popover__panel$controls includes 'js'.Consumer JS owns the toggle. Pair with aria-expanded on the trigger.
JS attribute hook.popover[data-open="true"] .popover__panel$controls includes 'js'.Useful for data-attribute-driven state bindings.

Placements

Each placement modifier repositions the panel and recolours the arrow's visible edge so the triangle still points back at the trigger. Logical-inset properties keep the layout correct under dir="rtl".

ModifierPanel anchorArrow edge
.is-top (default)inset-block-end: 100%, centered on the inline axis.Triangle hangs off the panel's block-end, pointing down.
.is-bottominset-block-start: 100%, centered on the inline axis.Triangle sits on the panel's block-start, pointing up.
.is-leftinset-inline-end: 100%, centered on the block axis.Triangle sits on the panel's inline-end, pointing inline-end.
.is-rightinset-inline-start: 100%, centered on the block axis.Triangle sits on the panel's inline-start, pointing inline-start.

The bare .popover (no placement modifier) renders identically to .popover.is-top — both are emitted so consumers can drive placement purely by class without relying on the absence of a modifier.

Combinations

The framework's shared colour combinations apply to the .popover__panel rather than the root, so the trigger keeps the host-surface colouring and the popover identity only surfaces on reveal. Add .is-primary, .is-danger, etc. to the root — the panel repaints automatically.

combination markup
<span class="popover is-primary">
  <button class="popover__trigger" type="button">Branded popover</button>
  <div class="popover__panel">
    <div class="popover__body">Panel paints in the <code>primary</code> combination.</div>
  </div>
</span>

Stacking

Popover sits at z-index: 1070 by default — above dropdown panels (10) and modal backdrops (1055), but below tooltips (1080) and toast notifications (1090). User-feedback toasts can therefore still appear over an open popover. The token is exposed as $z-index so consumers shipping additional layered chrome can retune in one place.

Variables

VariableDefaultNotes
$selector'.popover'Root.
$trigger-selector'.popover__trigger'Visible trigger element (button, anchor, label).
$panel-selector'.popover__panel'Absolutely-positioned panel revealed by the active mode.
$header-selector'.popover__header'Optional bold heading slot with a hairline divider below.
$body-selector'.popover__body'Main content slot.
$min-width12remMinimum panel width.
$max-width20remMaximum panel width.
$padding-y$size-extra-smallVertical padding on header / body slots.
$padding-x$size-smallHorizontal padding on header / body slots.
$offset$size-extra-smallStack distance between trigger and panel. Also drives the arrow inset.
$border-radius$global-border-radiusPanel surface radius.
$z-index1070Panel stacking order. Above dropdown (10), below tooltip (1080).
$arrowtrueEmit the CSS triangle ::before the panel. Set to false to strip it entirely.
$arrow-size6pxHalf-width of the arrow triangle in CSS pixels.
$shadow0 6px 20px rgb(0 0 0 / 0.15)Drop shadow tuned to read consistently across light / dark themes.
$transition-duration$global-transition-durationOpacity + visibility transition. Stripped under prefers-reduced-motion: reduce.
$controls$global-controls (('css', 'js'))Reveal mechanism(s). See Modes above.
$default-combination$color-combination-defaultBg/fg tuple used when no .is-* modifier is present.

Override example

app.scss
@use '@adnap/krysalicss/module/popover' with (
  $min-width: 16rem,
  $max-width: 24rem,
  $offset: 8px,
  $controls: ('css', 'js'),
);

Tokens consumed

TokenUsed for
--kc-popover-bg / --kc-popover-fgDefault panel surface. Falls back through --kc-bg / --kc-fg so the panel tracks the active theme.
--kc-{label}-bg / --kc-{label}-fgEach combination modifier (.popover.is-primary, .popover.is-danger, …).
currentColorArrow visible edge and the 12% hairline border / divider.

Accessibility

  • The CSS-only reveal covers pointer (:hover) and keyboard (:focus-within). A fully keyboard-complete popover (dismiss on Esc, focus trap, outside-click) requires consumer JS toggling aria-expanded and managing focus — same rationale as Dropdown.
  • Forced-colors mode strips background and shadow; a CanvasText border preserves the panel silhouette on Windows High Contrast.