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')
); .is-bottom
<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')
); .is-right
<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')
); .is-active (JS class hook, panel pinned open)
.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')
); [data-open='true'] (JS attribute hook, panel pinned open)
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')
); .is-primary combination tint
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')
); 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.
<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.
<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).
<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:
@use '@adnap/krysalicss/module/popover' with (
$controls: ('css'),
); To ship only the JS hooks:
@use '@adnap/krysalicss/module/popover' with (
$controls: ('js'),
); Reveal matrix
| Trigger | Selector | Gating | Notes |
|---|---|---|---|
| Pointer hover | .popover:hover .popover__panel | Always emitted. | Mouse / touch hover. Pointer-only — no keyboard equivalent. |
| Keyboard focus | .popover:focus-within .popover__panel | Always 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".
| Modifier | Panel anchor | Arrow edge |
|---|---|---|
.is-top (default) | inset-block-end: 100%, centered on the inline axis. | Triangle hangs off the panel's block-end, pointing down. |
.is-bottom | inset-block-start: 100%, centered on the inline axis. | Triangle sits on the panel's block-start, pointing up. |
.is-left | inset-inline-end: 100%, centered on the block axis. | Triangle sits on the panel's inline-end, pointing inline-end. |
.is-right | inset-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.
<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
| Variable | Default | Notes |
|---|---|---|
$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-width | 12rem | Minimum panel width. |
$max-width | 20rem | Maximum panel width. |
$padding-y | $size-extra-small | Vertical padding on header / body slots. |
$padding-x | $size-small | Horizontal padding on header / body slots. |
$offset | $size-extra-small | Stack distance between trigger and panel. Also drives the arrow inset. |
$border-radius | $global-border-radius | Panel surface radius. |
$z-index | 1070 | Panel stacking order. Above dropdown (10), below tooltip (1080). |
$arrow | true | Emit the CSS triangle ::before the panel. Set to false to strip it entirely. |
$arrow-size | 6px | Half-width of the arrow triangle in CSS pixels. |
$shadow | 0 6px 20px rgb(0 0 0 / 0.15) | Drop shadow tuned to read consistently across light / dark themes. |
$transition-duration | $global-transition-duration | Opacity + visibility transition. Stripped under prefers-reduced-motion: reduce. |
$controls | $global-controls (('css', 'js')) | Reveal mechanism(s). See Modes above. |
$default-combination | $color-combination-default | Bg/fg tuple used when no .is-* modifier is present. |
Override example
@use '@adnap/krysalicss/module/popover' with (
$min-width: 16rem,
$max-width: 24rem,
$offset: 8px,
$controls: ('css', 'js'),
); Tokens consumed
| Token | Used for |
|---|---|
--kc-popover-bg / --kc-popover-fg | Default panel surface. Falls back through --kc-bg / --kc-fg so the panel tracks the active theme. |
--kc-{label}-bg / --kc-{label}-fg | Each combination modifier (.popover.is-primary, .popover.is-danger, …). |
currentColor | Arrow 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 onEsc, focus trap, outside-click) requires consumer JS togglingaria-expandedand managing focus — same rationale as Dropdown. - Forced-colors mode strips background and shadow; a
CanvasTextborder preserves the panel silhouette on Windows High Contrast.