Skip to main content
Lightning Jar - Web Studio Lightning Jar Wordmark

The Platform Gave Us the Chat Widget

A watercolor illustration of a vintage sewing machine

The Platform Gave Us the Chat Widget

When we put our chat agent, Ask Eljay, on every page of this site, we assumed we were signing up for the usual floating-widget grind: z-index wars, click-outside handlers, focus management, an Escape listener, and a positioning system to keep the panel glued to its button. We wrote almost none of it. The browser now ships the hard parts of a chat widget as two native APIs, and this post is the field report on using them, including the two traps we fell into.

The popover API replaces the widget plumbing

The panel is one element:

<section id="chat-panel" popover="auto" aria-label="Ask Eljay chat">
  ...chat lives here...
</section>

<button popovertarget="chat-panel">Ask Eljay</button>

That is the whole open-and-close system. No click handler on the button, no state for visibility, no listener on the window. popover="auto" gives you, from the platform:

  • Top-layer rendering. The panel paints above everything, including anything with a desperate z-index. We deleted our stacking concerns outright.
  • Light dismiss. Click anywhere outside and it closes. Press Escape and it closes. Zero code.
  • Focus return. Close the panel and focus goes back to the button that opened it, which is the behavior screen reader and keyboard users need and almost nobody hand-implements correctly.
  • Implicit semantics. The invoker button gets its expanded state exposed to assistive tech automatically.

The one bit of JavaScript we kept is a toggle listener on the panel, because we swap the launcher's icon and move focus into the chat input when it opens. Everything else that used to be widget code is now an attribute.

A nice side effect: because a closed popover stays in the DOM, the conversation survives close and reopen for free. Our chat state management for the panel is: none.

Anchor positioning glues the panel to the button

The second API pins the panel to the launcher so the pair can never drift apart:

.launcher {
  anchor-name: --chat-launcher;
}

@supports (anchor-name: --a) {
  #chat-panel {
    position-anchor: --chat-launcher;
    bottom: calc(anchor(top) + 0.75rem);
    right: anchor(right);
  }
}

The panel's bottom edge sits 12 pixels above the button's top, right edges aligned, at every viewport size, with no JavaScript measuring anything. Browsers without anchor support fall through the @supports gate to ordinary fixed positioning, which is exactly where the panel would have been anyway. Progressive enhancement with a one-class fallback.

Trap one: the UA stylesheet resets your text color

Our panel came up with black text on a dark navy background, at a contrast ratio of 1.33. We had overridden the popover's default background but not its color, and the popover UA stylesheet sets both. color resets to CanvasText, which is black in a light color scheme, no matter what your page's inherited color was. If your site is dark-themed, set an explicit text color on every popover. An accessibility scan caught this one for us; eyes alone might have shipped it.

Trap two: the closed state hides with a polite display:none

This is the one that bit us in production. The UA rule that hides closed popovers is ordinary, not !important. Any always-on display utility you put on the element, and utility-class workflows put display classes on everything, overrides the hidden state entirely. Our panel carried a grid class for its internal layout, which meant the "closed" panel was quietly rendering on every page. And because we had also set inset: auto for the anchor fallback, it did not even render where we would have seen it. It fell into static flow at the bottom of the page, below the fold, past the end of the content.

The fix is to never put a display class on a popover. Let the closed state come from the UA, and key the open display off the state selector:

#chat-panel:popover-open {
  display: grid;
}

The lesson generalizes: the popover API's defaults are a contract, and utility classes can breach it silently. Audit display, color, margin, and inset on any element you make a popover, because the UA sets all four.

The part we still wrote ourselves

The platform does not know that a chat SDK weighs half a megabyte. The widget renders instantly on page load, but the AI machinery does not load until a person actually touches, clicks into, or focuses the chat. That gesture gate is ordinary dynamic import wired to focus and pointer events, and it does double duty: page weight stays down for the majority who never open the chat, and automated traffic that never produces a human gesture never costs us an inference call.

That split felt right everywhere in this build. The browser owns presence, position, dismissal, and focus. We own what loads when, and what it costs. The wins from handing the first half to the platform were real: less code, better keyboard and screen reader behavior than we would have hand-rolled, and two sharp lessons about reading the UA stylesheet before styling over it.

headshot of Kevin Peckham
Kevin Peckham
Principal, Lightning Jar

Ask Eljay

Ask about the studio's work, research, packages, or writing. A few starters:

Answers come from this site's own content and link their sources. For anything that matters, email hello@lightningjar.com.