Agustin Barrientos
All posts

The Modern Tooltip Is a Systems Problem

Want more of these in your Google results?

Someone files a small ticket. Show a little profile card when you hover a username, like the ones on every social app. You reach for the obvious version. Absolutely position a <div> next to the handle, measure the trigger with getBoundingClientRect(), show the card on mouseenter, hide it on mouseleave. It looks done in twenty minutes. Then it meets a real page. Scroll, and the card stays pinned where the trigger used to be. Resize, and it drifts. Put the handle near the right edge, and the card runs off screen because the math never asked where the viewport was. Tab through with the keyboard and the card never shows, because hover is the only way in. Open it on a phone and there’s no hover at all.

Drag the handle toward a corner in this first demo and watch the card clip off the edge. Then try to reach it with the Tab key.

naive-positioning
import { LitElement, html, css } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';

/**
 * The broken baseline hovercard, the version a junior reaches for first.
 *
 * The card is an absolutely positioned `<div>` placed with `getBoundingClientRect()`, shown on `mouseenter` only.
 * Drag the trigger toward an edge of the stage and the card clips, because the math never checks the viewport and nothing flips it.
 * The card is also a `<div>` with no role, opened on hover with no keyboard path, so a Tab key never reaches it.
 *
 * @element demo-the-modern-tooltip-is-a-systems-problem-hovercard-broken
 */
@customElement( 'demo-the-modern-tooltip-is-a-systems-problem-hovercard-broken' )
export class HovercardBrokenDemo extends LitElement {

	/**
	 * The trigger's left offset inside the stage, in pixels, updated as the reader drags it.
	 */
	@state()
	private x = 40;

	/**
	 * The trigger's top offset inside the stage, in pixels, updated as the reader drags it.
	 */
	@state()
	private y = 40;

	/**
	 * Whether the card is currently shown, toggled by `mouseenter` and `mouseleave` only.
	 */
	@state()
	private open = false;

	/**
	 * The card's resolved top-left, computed from the trigger's box with no viewport check.
	 */
	@state()
	private cardPos = { left: 0, top: 0 };

	/**
	 * The trigger button, measured with `getBoundingClientRect()` to place the card.
	 */
	@query( '.trigger' )
	private $trigger!: HTMLButtonElement;

	/**
	 * The stage the trigger is dragged around, used to keep the drag in local coordinates.
	 */
	@query( '.stage' )
	private $stage!: HTMLDivElement;

	/**
	 * Styles for the harness, scoped to its shadow root.
	 */
	static styles = css`
		:host { display: block; height: 100%; font-family: var(--font-sans, system-ui, sans-serif); color: var(--ink, #17171a); }
		.stage {
			position: relative;
			height: 100%;
			min-height: 320px;
			overflow: hidden;
			background:
				repeating-linear-gradient(0deg, transparent, transparent 23px, var(--line-soft, #f0efec) 23px, var(--line-soft, #f0efec) 24px),
				var(--bg, #fcfcfb);
		}
		.hint {
			position: absolute;
			top: 12px;
			left: 12px;
			right: 12px;
			margin: 0;
			font-family: var(--font-mono, monospace);
			font-size: 11px;
			line-height: 1.6;
			color: var(--ink-soft, #56565c);
		}
		.trigger {
			position: absolute;
			padding: 7px 12px;
			border-radius: 7px;
			border: 1px solid var(--line, #e7e6e2);
			background: var(--surface, #fff);
			color: var(--accent, #2257e6);
			font: inherit;
			font-weight: 600;
			cursor: grab;
			touch-action: none;
			user-select: none;
		}
		.trigger:active { cursor: grabbing; }
		.card {
			position: absolute;
			z-index: 9999;
			width: 220px;
			border: 1px solid var(--line, #e7e6e2);
			border-radius: 12px;
			padding: 14px;
			background: var(--surface, #fff);
			box-shadow: 0 12px 30px rgb(0 0 0 / 0.16);
		}
		.card h3 { margin: 0; font-size: 15px; }
		.card p { margin: 6px 0 0; font-size: 13px; line-height: 1.5; color: var(--ink-soft, #56565c); }
	`;

	/**
	 * Starts a drag, capturing the pointer so the trigger follows it across the stage.
	 *
	 * @param event - The pointerdown on the trigger.
	 */
	private startDrag = ( event: PointerEvent ): void => {
		event.preventDefault();
		this.$trigger.setPointerCapture( event.pointerId );
		this.$trigger.addEventListener( 'pointermove', this.onDrag );
		this.$trigger.addEventListener( 'pointerup', this.endDrag, { once: true } );
	};

	/**
	 * Moves the trigger to the pointer, in stage-local coordinates, then re-places the card from its new box.
	 *
	 * @param event - The pointermove during a drag.
	 */
	private onDrag = ( event: PointerEvent ): void => {
		const stage = this.$stage.getBoundingClientRect();
		this.x = event.clientX - stage.left - 28;
		this.y = event.clientY - stage.top - 16;
		this.placeCard();
	};

	/**
	 * Ends the drag and detaches the move listener.
	 *
	 * @param event - The pointerup that ends the drag.
	 */
	private endDrag = ( event: PointerEvent ): void => {
		this.$trigger.releasePointerCapture( event.pointerId );
		this.$trigger.removeEventListener( 'pointermove', this.onDrag );
	};

	/**
	 * Places the card directly below the trigger using `getBoundingClientRect()`, with no viewport or edge check.
	 *
	 * This is the bug on purpose: when the trigger nears the right or bottom edge, the card runs straight off the stage.
	 */
	private placeCard(): void {
		const stage = this.$stage.getBoundingClientRect();
		const trigger = this.$trigger.getBoundingClientRect();
		this.cardPos = {
			left: trigger.left - stage.left,
			top: trigger.bottom - stage.top + 8,
		};
	}

	/**
	 * Shows the card on hover, the only path this version offers.
	 */
	private show = (): void => {
		this.placeCard();
		this.open = true;
	};

	/**
	 * Hides the card when the pointer leaves the trigger.
	 */
	private hide = (): void => {
		this.open = false;
	};

	/**
	 * Renders the stage, the draggable trigger, and the absolutely positioned card.
	 */
	render() {
		return html`
			<div class="stage">
				<p class="hint">Drag the handle toward a corner, then hover it. The card runs off the edge. Now try to Tab to it.</p>
				<button
					type="button"
					class="trigger"
					style="left:${ this.x }px; top:${ this.y }px;"
					@pointerdown=${ this.startDrag }
					@mouseenter=${ this.show }
					@mouseleave=${ this.hide }
				>@agustin</button>
				${ this.open
					? html`
						<div class="card" style="left:${ this.cardPos.left }px; top:${ this.cardPos.top }px;">
							<h3>Agustin Barrientos</h3>
							<p>Frontend lead. Writes The Senior Eye.</p>
						</div>
					`
					: null }
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-the-modern-tooltip-is-a-systems-problem-hovercard-broken': HovercardBrokenDemo;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

None of those failures are exotic. They’re the default behavior of the naive approach, and exactly the parts a tooltip library used to paper over. In 2026 the platform owns most of that work, and a chunk of it ships cross-engine today. So the job stops being “implement a hovercard” and becomes a sorting problem. Which parts should you stop writing because the browser has a primitive for them, and which should you not pretend the platform has solved everywhere yet.

A quick note on letting AI code this

Hand this to an AI and you get working code in seconds, so do that. Ask vaguely for a hovercard, though, and the generic answer comes back, Floating UI plus a mouseenter handler, no focus path, no edge flipping, nothing for touch. It runs, and it sits low on a wide quality scale. The leverage is knowing what to ask for. You want a native anchored popover, the browser-driven flip, interestfor as progressive enhancement, the card classified by what it contains, and a click fallback that survives where the new trigger API doesn’t. Don’t worry about those API names yet, the article earns each one. The point is that the answer’s quality depends on knowing which contracts to ask for. Build it by hand once and you earn the eye to tell which one you got. What stays with you is the work the model can’t do. You’re the one choosing the pattern, verifying behavior in a real browser, weighing the accessibility tradeoffs, reading a support table before you trust a feature, and catching the confident answer that happens to be wrong.

The contracts hiding in a hovercard

A junior sees one widget. A senior sees a stack of separate contracts, some of which used to be your code and some the platform now owns. Sort them before you write anything, because “who owns this” differs by row, and getting it wrong is how you reimplement something the browser already does, or ship something it doesn’t actually support yet.

Table How the platform reabsorbs the old tooltip stack, layer by layer.
LayerOld instinctModern platform answerThe question to ask
Layeringz-index: 9999Popover top layerShould this escape normal layout?
PlacementFloating UI / PopperCSS anchor positioningCan CSS own the geometry now?
FlippingJS viewport mathposition-try-fallbacksCan the browser solve edge collisions?
Triggeringmouseenter / mouseleaveinterestforIs this shippable, progressive, or experimental?
Semanticshand-rolled ARIApartly native, partly yoursIs this content reachable and correctly classified?

That table is the whole article. New platform APIs don’t remove the judgment from this work, they relocate it. The question used to be “how do I implement hover-to-open positioning?” Now it’s two questions instead. Which of these can I delete, and which can I only lean on as an enhancement. We’ll build the card by answering both, one row at a time, shippable parts first.

The bar we’re holding it to

Concretely, the finished component should:

  • open from a real <button> handle, since opening a card in place is a control rather than a navigation;
  • classify the card by its contents, so the moment it holds a focusable Follow button, treat it as a non-modal dialog;
  • sit next to the handle and flip to stay on screen near an edge, with no positioning JavaScript;
  • open on hover, focus, and long-press where the browser supports it;
  • keep the Follow action reachable by keyboard, and on touch and in Firefox and Safari, through a plain click;
  • dismiss with an in-card Close control, the Esc key, and an outside click;
  • back off its animation when the user asks for reduced motion;
  • ship without a positioning library, and degrade to a click-opened card where the new trigger API is missing.

Hold it to a single flow. Tab to the handle, and the open card is the next stop, with Close and Follow reachable in order. No mouse.

What you’ll need

Be comfortable with TypeScript and the DOM. No Lit experience required; I’ll explain the Lit-specific bits as they come up. You’ll want Lit installed, plus your browser devtools (the Elements panel for the top layer, the Accessibility panel to read the card’s role and name), and a screen reader for five honest minutes at the end. Skip the design tool, since the card’s looks are incidental.

Try it yourself first

Give it a real attempt first. Start from the Popover API and a <button> rather than a <div> with a mouseenter listener, and see how much you get for free. Then reach for CSS anchor positioning to place the card, and only after that go looking for how to open it on hover. The order matters, so notice which parts feel like platform features and which feel like your problem.

Building it

Step 1: the popover substrate

Start with the layering and dismissal, because the platform hands you both. The Popover API puts an element in the browser’s top layer, above everything else with no z-index, and gives it light dismiss (an outside click or the Esc key closes it) for free. You open it declaratively from a button, no script:

scratch.html
<!-- scratch.html -->
<button type="button" class="trigger" popovertarget="profile-card" popovertargetaction="show">@agustin</button>

<div id="profile-card" class="card" popover>
	<button type="button" popovertarget="profile-card" popovertargetaction="hide" aria-label="Close preview">&times;</button>
	<h2>Agustin Barrientos</h2>
	<p>Frontend lead. Writes The Senior Eye.</p>
</div>

Two small decisions here do more than they look. The first is popovertargetaction="show" on the handle, paired with popovertargetaction="hide" on the Close button. Each control has one job, the handle opens and the Close button closes, where the default toggle would make a single button do both. Keeping open and close as two single-purpose actions pays off once step 5 adds a second way to open the card. The second is the in-card Close button with popovertargetaction="hide". Light dismiss and Esc are real, but a visible Close control gives keyboard and pointer users an obvious way out before we get into the richer dialog semantics. That click-to-open and click-to-close markup is the load-bearing fallback for the rest of the build, so it stays put through every later step.

Click the handle to open this one. Notice it lands in the top layer, dismisses on an outside click, and never clips because of an ancestor’s overflow.

popover-substrate
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';

/**
 * The popover substrate, step one of the build.
 *
 * A `<button>` opens a `popover` (auto) through `popovertarget` with an explicit `popovertargetaction="show"`.
 * An in-card Close button hides it with `popovertargetaction="hide"`.
 * There is no JavaScript: the platform puts the card in the top layer, dismisses it on an outside click or Esc, and never fights any `z-index`.
 * Placement is the default centered popover for now; CSS anchor positioning moves it next to the trigger in the next step.
 *
 * @element demo-the-modern-tooltip-is-a-systems-problem-hovercard-popover
 */
@customElement( 'demo-the-modern-tooltip-is-a-systems-problem-hovercard-popover' )
export class HovercardPopoverDemo extends LitElement {

	/**
	 * Styles for the harness, scoped to its shadow root.
	 *
	 * The card is styled through the `:popover-open` state and animated with `@starting-style`, with no positioning rules yet.
	 */
	static styles = css`
		:host {
			display: block;
			height: 100%;
			font-family: var(--font-sans, system-ui, sans-serif);
			color: var(--ink, #17171a);
		}

		.stage {
			display: grid;
			align-content: start;
			justify-items: center;
			gap: 18px;
			height: 100%;
			/* Pin the lede and trigger to the top and keep a tall floor, so the default-centered popover lands in the lower half well below the trigger: the compact card opens over open space, never over the lede above it. */
			min-height: 420px;
			padding: 24px;
			text-align: center;
		}

		.lede {
			margin: 0;
			font-size: 14px;
			line-height: 1.6;
			color: var(--ink-soft, #56565c);
			max-width: 30ch;
		}

		.trigger {
			font: inherit;
			font-weight: 600;
			color: var(--accent, #2257e6);
			background: none;
			border: none;
			padding: 0;
			cursor: pointer;
		}

		.trigger:hover {
			text-decoration: underline;
		}

		.card {
			width: 280px;
			border: 1px solid var(--line, #e7e6e2);
			border-radius: 14px;
			padding: 16px;
			background: var(--surface, #fff);
			color: var(--ink, #17171a);
			box-shadow: 0 16px 40px rgb(0 0 0 / 0.2);
			opacity: 0;
			translate: 0 6px;
			text-align: start;
			transition:
				opacity 0.18s ease,
				translate 0.18s ease,
				display 0.18s allow-discrete,
				overlay 0.18s allow-discrete;
		}

		.card:popover-open {
			opacity: 1;
			translate: 0 0;
		}

		@starting-style {
			.card:popover-open {
				opacity: 0;
				translate: 0 6px;
			}
		}

		.head {
			display: flex;
			align-items: flex-start;
			justify-content: space-between;
			gap: 8px;
		}

		.name {
			margin: 0;
			font-size: 16px;
		}

		.handle {
			margin: 2px 0 0;
			font-size: 13px;
			color: var(--ink-faint, #71717b);
		}

		.bio {
			margin: 10px 0 0;
			font-size: 13px;
			line-height: 1.5;
			color: var(--ink-soft, #56565c);
		}

		.close {
			width: 28px;
			height: 28px;
			border-radius: 50%;
			border: 1px solid var(--line, #e7e6e2);
			background: var(--bg, #fcfcfb);
			color: var(--ink-soft, #56565c);
			font-size: 16px;
			line-height: 1;
			cursor: pointer;
		}

	`;

	/**
	 * Renders the trigger and the centered popover substrate.
	 */
	render() {
		return html`
			<div class="stage">
				<p class="lede">
					Click the handle to open the card.
					Click outside it or press Esc to dismiss.
					It lives in the top layer, so nothing clips it.
				</p>
				<button type="button" class="trigger" popovertarget="popover-card" popovertargetaction="show">@agustin</button>
				<div id="popover-card" class="card" popover>
					<div class="head">
						<div>
							<h2 class="name">Agustin Barrientos</h2>
							<p class="handle">@agustin</p>
						</div>
						<button
							type="button"
							class="close"
							popovertarget="popover-card"
							popovertargetaction="hide"
							aria-label="Close Agustin Barrientos preview"
						>&times;</button>
					</div>
					<p class="bio">Frontend lead. Writes The Senior Eye.</p>
				</div>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-the-modern-tooltip-is-a-systems-problem-hovercard-popover': HovercardPopoverDemo;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

The base Popover API (popover, popovertarget, light dismiss, the top layer) is Baseline and broadly shippable on its own, though combining it with shadow DOM, top-layer animation, and focus still warrants a test pass. The easy mistake here is reaching for a library or a portal to escape overflow: hidden and a z-index stack. That whole class of problem is what the top layer deletes.

Step 2: anchored placement

The card opens centered, which isn’t where a hovercard belongs. CSS anchor positioning ties it to the handle without measuring anything in JavaScript. Give the trigger an anchor-name, point the card at it with position-anchor, and place it with position-area, which drops the card onto a tile of an implicit three by three grid around the anchor:

scratch.css
/* scratch.css */
.trigger {
	anchor-name: --profile-trigger;
}

.card {
	position: fixed;
	position-anchor: --profile-trigger;
	position-area: block-end;
	margin: 0.5rem;
}

position-area: block-end puts the card below the handle in a normal top-to-bottom flow, and the logical keywords keep it correct in other writing modes. That’s the entire positioning logic, with no getBoundingClientRect() to call and nothing to recompute on resize or scroll. The browser keeps the card glued to the handle as the page moves.

Change the side in the control and watch the card move around the handle, all in CSS.

anchored-placement
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';

/**
 * Anchored placement, step two of the build.
 *
 * The trigger gets an `anchor-name`, and the card sets `position-anchor` plus `position-area` to sit on a chosen side of it.
 * Change the `position-area` control and the card moves around the trigger with no positioning JavaScript, the geometry the browser now owns.
 *
 * @element demo-the-modern-tooltip-is-a-systems-problem-hovercard-anchored
 */
@customElement( 'demo-the-modern-tooltip-is-a-systems-problem-hovercard-anchored' )
export class HovercardAnchoredDemo extends LitElement {

	/**
	 * The current `position-area` value, bound to the control and written to a custom property the card reads.
	 */
	@state()
	private area = 'block-end';

	/**
	 * The `position-area` values offered in the control, the four readable logical sides the article teaches.
	 */
	private readonly areas = [ 'block-end', 'block-start', 'inline-end', 'inline-start' ];

	/**
	 * Styles for the harness, scoped to its shadow root.
	 *
	 * The chosen side rides in on the `--demo-area` custom property, so the `position-area` declaration stays in the stylesheet and only the value comes from the template.
	 */
	static styles = css`
		:host {
			display: block;
			height: 100%;
			font-family: var(--font-sans, system-ui, sans-serif);
			color: var(--ink, #17171a);
		}

		/* Stage on top, controls below: a full-width stage leaves a card-width of clear space on each inline side of the centered handle, so inline-start and inline-end land on opposite sides rather than both flipping to the one side that has room. */
		.wrap {
			display: grid;
			grid-template-rows: 1fr auto;
			height: 100%;
		}

		.stage {
			display: grid;
			place-items: center;
			/* The card can open on any side the reader picks, so the trigger stays centered with a card-width of room left and right and a card-height of room above and below: every position-area then lands on its own side instead of falling back to another one.
			   The stage owns the floor (rather than the wrap) so the clearance does not shrink as the controls grow. */
			min-height: 420px;
			padding: 24px;
		}

		.controls {
			padding: 18px;
			display: flex;
			flex-wrap: wrap;
			align-items: center;
			gap: 14px 24px;
			justify-content: center;
			border-top: 1px solid var(--line, #e7e6e2);
		}

		label {
			display: block;
			min-width: 180px;
		}

		.lbl {
			font-family: var(--font-mono, monospace);
			font-size: 10px;
			letter-spacing: 0.08em;
			text-transform: uppercase;
			color: var(--ink-soft, #56565c);
			display: block;
			margin-bottom: 6px;
		}

		select {
			width: 100%;
			font: inherit;
			font-size: 13px;
			padding: 8px 10px;
			border: 1px solid var(--line, #e7e6e2);
			border-radius: 7px;
			background: var(--bg, #fcfcfb);
			color: var(--ink, #17171a);
		}

		.hint {
			font-family: var(--font-mono, monospace);
			font-size: 10.5px;
			line-height: 1.6;
			color: var(--ink-soft, #56565c);
			margin: 0;
			max-width: 34ch;
		}

		.trigger {
			anchor-name: --anchored-trigger;
			font: inherit;
			font-weight: 600;
			color: var(--accent, #2257e6);
			background: none;
			border: none;
			padding: 0;
			cursor: pointer;
		}

		.trigger:hover {
			text-decoration: underline;
		}

		.card {
			position-anchor: --anchored-trigger;
			position: fixed;
			position-area: var(--demo-area, block-end);
			inset: auto;
			margin: 8px;
			width: 220px;
			border: 1px solid var(--line, #e7e6e2);
			border-radius: 12px;
			padding: 14px;
			background: var(--surface, #fff);
			color: var(--ink, #17171a);
			box-shadow: 0 14px 36px rgb(0 0 0 / 0.18);
		}

		.card h3 {
			margin: 0;
			font-size: 15px;
		}

		.card p {
			margin: 6px 0 0;
			font-size: 13px;
			line-height: 1.5;
			color: var(--ink-soft, #56565c);
		}

		@media (width <= 620px) {
			.controls {
				flex-direction: column;
				align-items: stretch;
			}

			label {
				min-width: 0;
			}
		}
	`;

	/**
	 * Updates the chosen `position-area` from the control, narrowing the target with `instanceof` so no cast is needed.
	 *
	 * @param event - The change event from the select.
	 */
	private onAreaChange = ( event: Event ): void => {
		if ( event.target instanceof HTMLSelectElement ) {
			this.area = event.target.value;
		}
	};

	/**
	 * Renders the stage with the always-open card and the placement control.
	 *
	 * The card stays open here so the reader can watch placement change live as they pick a `position-area`.
	 */
	render() {
		return html`
			<div class="wrap">
				<div class="stage">
					<button type="button" class="trigger">@agustin</button>
					<div class="card" style="--demo-area:${ this.area };">
						<h3>Agustin Barrientos</h3>
						<p>position-area: ${ this.area }</p>
					</div>
				</div>
				<div class="controls">
					<label>
						<span class="lbl">position-area</span>
						<select .value=${ this.area } @change=${ this.onAreaChange }>
							${ this.areas.map( ( value ) => html`<option value=${ value }>${ value }</option>` ) }
						</select>
					</label>
					<p class="hint">The card anchors to the handle. Pick a side and it moves there, no JavaScript measuring boxes.</p>
				</div>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-the-modern-tooltip-is-a-systems-problem-hovercard-anchored': HovercardAnchoredDemo;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

As of today CSS anchor positioning is Baseline 2026, with the current syntax (position-area, position-anchor, anchor-name) in Chrome, Edge, Firefox, and Safari. One gotcha is that the feature landed in stages, so if you’re targeting older versions, position-area was once called inset-area, position-try-fallbacks (next step) had the earlier name position-try-options, and some single-keyword position-area values like block-end only had partial behavior in earlier anchor-positioning releases, so a low support floor is a reason to check the exact values you use. The current names are the broadly supported path.

Step 3: flipping at the edge

Placing the card on one side is fine until that side runs out of room. This used to be a wall of viewport math, and it’s now one declaration. position-try-fallbacks gives the browser a list of alternative placements to try when the preferred one would overflow:

scratch.css
/* scratch.css, on the card */
.card {
	position-area: block-end;
	position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}

flip-block and flip-inline tell the browser what to do at the edge. If the card doesn’t fit below, try above; if it doesn’t fit on the end side, try the start side. The browser uses the first option that keeps the card inside the constrained box, and a single fallback entry can combine keywords, so flip-block flip-inline flips on both axes at once when neither single flip is enough. There are also flip-x and flip-y, but they’re newer and thinner on support, so stick with the logical flip-block and flip-inline pair.

Drag the handle toward the bottom or right edge of the constrained box and watch the card flip to the other side on its own.

edge-flipping
import { LitElement, html, css } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';

/**
 * Edge flipping, step three of the build.
 *
 * The card prefers the `block-end` side, and `position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline` lets the browser flip it to the opposite side, or to the opposite corner when neither single flip fits, so it stays inside the constrained stage.
 * Drag the trigger toward the bottom or right edge and watch the card flip on its own, the viewport collision logic that used to be a wall of JavaScript.
 *
 * @element demo-the-modern-tooltip-is-a-systems-problem-hovercard-flip
 */
@customElement( 'demo-the-modern-tooltip-is-a-systems-problem-hovercard-flip' )
export class HovercardFlipDemo extends LitElement {

	/**
	 * The trigger's left offset inside the stage, in pixels, updated as the reader drags it.
	 */
	@state()
	private x = 30;

	/**
	 * The trigger's top offset inside the stage, in pixels, updated as the reader drags it.
	 */
	@state()
	private y = 30;

	/**
	 * The draggable trigger, the anchor the card flips around.
	 */
	@query( '.trigger' )
	private $trigger!: HTMLButtonElement;

	/**
	 * The constrained stage, used to keep the drag in local coordinates.
	 */
	@query( '.stage' )
	private $stage!: HTMLDivElement;

	/**
	 * Styles for the harness, scoped to its shadow root.
	 *
	 * The stage is a small `position: relative` box with `overflow: hidden`, so it is the card's containing block and its visible edge, which is what makes the flip observable inside the iframe.
	 */
	static styles = css`
		:host { display: block; height: 100%; font-family: var(--font-sans, system-ui, sans-serif); color: var(--ink, #17171a); }
		.frame { display: grid; place-items: center; height: 100%; min-height: 320px; padding: 20px; }
		.stage {
			position: relative;
			width: min(420px, 100%);
			height: 260px;
			overflow: hidden;
			border: 1px dashed var(--line, #e7e6e2);
			border-radius: 10px;
			background:
				repeating-linear-gradient(0deg, transparent, transparent 23px, var(--line-soft, #f0efec) 23px, var(--line-soft, #f0efec) 24px),
				var(--bg, #fcfcfb);
		}
		.hint { position: absolute; top: 8px; left: 10px; right: 10px; margin: 0; font-family: var(--font-mono, monospace); font-size: 10.5px; line-height: 1.5; color: var(--ink-soft, #56565c); pointer-events: none; }
		.trigger {
			position: absolute;
			anchor-name: --flip-trigger;
			padding: 6px 11px;
			border-radius: 7px;
			border: 1px solid var(--line, #e7e6e2);
			background: var(--surface, #fff);
			color: var(--accent, #2257e6);
			font: inherit;
			font-weight: 600;
			cursor: grab;
			touch-action: none;
			user-select: none;
			z-index: 1;
		}
		.trigger:active { cursor: grabbing; }
		.card {
			position-anchor: --flip-trigger;
			position: absolute;
			inset: auto;
			position-area: block-end;
			position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
			margin: 6px;
			width: 180px;
			border: 1px solid var(--line, #e7e6e2);
			border-radius: 10px;
			padding: 12px;
			background: var(--surface, #fff);
			box-shadow: 0 10px 26px rgb(0 0 0 / 0.16);
		}
		.card h3 { margin: 0; font-size: 14px; }
		.card p { margin: 5px 0 0; font-size: 12px; line-height: 1.5; color: var(--ink-soft, #56565c); }
	`;

	/**
	 * Starts a drag, capturing the pointer so the trigger follows it across the stage.
	 *
	 * @param event - The pointerdown on the trigger.
	 */
	private startDrag = ( event: PointerEvent ): void => {
		event.preventDefault();
		this.$trigger.setPointerCapture( event.pointerId );
		this.$trigger.addEventListener( 'pointermove', this.onDrag );
		this.$trigger.addEventListener( 'pointerup', this.endDrag, { once: true } );
	};

	/**
	 * Moves the trigger to the pointer, clamped to the stage so it cannot leave the box.
	 *
	 * @param event - The pointermove during a drag.
	 */
	private onDrag = ( event: PointerEvent ): void => {
		const stage = this.$stage.getBoundingClientRect();
		const next = { x: event.clientX - stage.left - 26, y: event.clientY - stage.top - 14 };
		this.x = Math.max( 0, Math.min( next.x, stage.width - 80 ) );
		this.y = Math.max( 0, Math.min( next.y, stage.height - 32 ) );
	};

	/**
	 * Ends the drag and detaches the move listener.
	 *
	 * @param event - The pointerup that ends the drag.
	 */
	private endDrag = ( event: PointerEvent ): void => {
		this.$trigger.releasePointerCapture( event.pointerId );
		this.$trigger.removeEventListener( 'pointermove', this.onDrag );
	};

	/**
	 * Renders the constrained stage, the draggable trigger, and the always-open card that flips to stay visible.
	 */
	render() {
		return html`
			<div class="frame">
				<div class="stage">
					<p class="hint">Drag the handle toward the bottom or right edge. The card flips to the other side to stay inside the box.</p>
					<button type="button" class="trigger" style="left:${ this.x }px; top:${ this.y }px;" @pointerdown=${ this.startDrag }>@agustin</button>
					<div class="card">
						<h3>Agustin Barrientos</h3>
						<p>The browser picks a side that fits.</p>
					</div>
				</div>
			</div>
		`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'demo-the-modern-tooltip-is-a-systems-problem-hovercard-flip': HovercardFlipDemo;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

This is the shippable half of the whole article, since the substrate, the placement, and the flip are all Baseline, cross-engine, and live today. Look at the JavaScript you just deleted. The getBoundingClientRect() calls, the resize and scroll listeners, the viewport collision logic, and the Floating UI or Popper dependency that carried all of it. Much of the positioning engine of a tooltip library, gone, replaced by five CSS declarations and a popover attribute. The libraries still earn their place for some jobs, like older browsers that predate anchor positioning, drawing an arrow that points back at the trigger, and bespoke collision strategies the flip keywords don’t express. For a hovercard on current engines, though, the platform covers the placement and collision behavior. That leaves the frontier. Can the browser own the triggering too, the hover and focus and long-press?

Step 4: wrap it in a documented component

Before the experimental part, fold what we have into a reusable component. I’m reaching for Lit because its decorators keep the element’s API readable and the boilerplate small. It lives in two files, the public types in types.ts and the component in index.ts. We’ll build it one concern at a time.

A hovercard takes a profile, so model it as one object in types.ts, not six loose attributes:

types.ts
// types.ts

/**
 * The profile data a `user-hovercard` renders.
 *
 * It is a plain data shape rather than a set of separate attributes, so a consumer can pass a whole record with one property binding instead of wiring six attributes by hand.
 */
export interface UserHovercardProfile {

	/**
	 * The account handle shown on the trigger, without the leading `@`.
	 * The component adds the `@` itself, so a caller passes `agustin`, not `@agustin`.
	 */
	handle: string;

	/**
	 * The person's display name, shown as the card's heading and used as its accessible name.
	 */
	name: string;

	/**
	 * A one-line bio shown under the name.
	 */
	bio: string;

	/**
	 * The follower count, shown as a stat on the card.
	 */
	followers: number;

	/**
	 * Whether the viewer already follows this account.
	 * A data field on the profile; later steps read it to label and press the Follow control once the card has one.
	 */
	following: boolean;
}

One field there is forward-looking. following belongs to the profile shape so the data stays stable across the build, but the click-only card at this step never reads it; the Follow button in step 6 is what turns it into a label.

Then the shell in index.ts carries the element, its TSDoc with the web-component tags, and the tag-map entry that makes document.querySelector( 'user-hovercard' ) resolve to the right type, so consumers get autocomplete.

index.ts
// index.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { HovercardPlacement, UserHovercardProfile } from './types';

/**
 * A username hovercard that opens a small profile card next to a handle.
 *
 * The trigger is a real `<button>` invoker, because opening a card in place is a UI control, not navigation.
 * Placement and edge flipping are CSS anchor positioning, so there is no positioning JavaScript.
 *
 * @element user-hovercard
 * @cssprop [--hovercard-width=16rem] - The card's width.
 */
@customElement( 'user-hovercard' )
export class UserHovercard extends LitElement {

}

declare global {
	interface HTMLElementTagNameMap {
		'user-hovercard': UserHovercard;
	}
}

Now the reactive surface. The profile is a @property so Lit re-renders when it changes, and attribute: false because an object binds as a property, never a string:

ts
/**
 * The profile the card renders.
 *
 * It is a property, not a set of attributes, so a consumer binds one record (`.profile=${user}`) instead of wiring each field by hand.
 * It is `attribute: false` because an object never round-trips through a string attribute, so there is no attribute to parse.
 */
@property( { attribute: false } )
profile?: UserHovercardProfile;

Notice the component does not provide a fake default profile. A default placement is configuration; a default person is data. If the parent forgets .profile, the card should not silently render sample content.

That contract lands in render(). Add nothing to the lit import, and make the first statement bail out when there is no profile, which both honors the contract and narrows the type so every this.profile read below it is safe:

index.ts
// index.ts, the top of render()
render() {
	if ( ! this.profile ) {
		return nothing;
	}

	// this.profile is defined from here down
}

The preferred placement maps onto the position-area from step 2, so its type is the set of sides the grid accepts. That union lives in types.ts, next to the profile shape:

types.ts
// types.ts

/**
 * Where the card sits relative to its trigger before any edge flipping.
 *
 * These map onto the CSS `position-area` grid: `block-end` is below the trigger, `block-start` is above it.
 * The browser still flips to the opposite side when the preferred side would clip, so this is a preference, not a guarantee.
 */
export type HovercardPlacement = 'block-end' | 'block-start' | 'inline-end' | 'inline-start';

With the type in hand, the placement property is a one-liner:

ts
/**
 * The card's preferred side before any edge flipping.
 *
 * Maps onto CSS `position-area`; the browser still flips to the opposite side when the preferred one would clip.
 */
@property( { type: String } )
placement: HovercardPlacement = 'block-end';

One detail bites with several cards on a page. Each card needs two kinds of unique wiring. One is the DOM ids for popovertarget, interestfor, and aria-labelledby, and the other is a unique CSS anchor name, a dashed-ident, for anchor-name and position-anchor. A shared value in either points every trigger at the first card, so the component mints one unique suffix per instance from a module-level counter:

index.ts
// index.ts, module scope, above the class

/**
 * A running id for hovercard instances, so each one mints a stable, unique suffix.
 *
 * A module-level counter beats `Math.random()` here: it cannot collide, and it reads the same on every render.
 */
let nextHovercardId = 0;
index.ts
// index.ts, a field on the class

/**
 * A unique suffix for this instance, so several cards on one page never collide.
 *
 * It backs both the DOM ids (`popovertarget`, `interestfor`, `aria-labelledby`) and the CSS anchor name, and a shared value would point every trigger at the first card.
 * It comes from a module-level counter rather than `Math.random()`, so it cannot collide and stays stable across renders.
 */
private readonly uid = `hc-${ nextHovercardId++ }`;

Drop two or three on one page and click any handle. Each one opens its own card.

user-hovercard-basic
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { HovercardPlacement, UserHovercardProfile } from './types';

/**
 * A running id for hovercard instances, so each one mints a stable, unique suffix.
 *
 * A module-level counter beats `Math.random()` here: it cannot collide, and it reads the same on every render.
 */
let nextHovercardId = 0;

/**
 * The placements `position-area` accepts, used to clamp the public `placement` before it reaches the style string.
 */
const HOVERCARD_PLACEMENTS = new Set<HovercardPlacement>( [
	'block-end',
	'block-start',
	'inline-end',
	'inline-start',
] );

/**
 * A username hovercard that opens a small profile card next to a handle, the step-four baseline.
 *
 * The trigger is a real `<button>` invoker, because opening a card in place is a UI control, not navigation.
 * The card is a `popover` (auto), so it lands in the top layer and gets light dismiss and Esc for free, and a click on the handle opens it through `popovertarget` with `popovertargetaction="show"`.
 * Placement and edge flipping are CSS anchor positioning, so there is no positioning JavaScript.
 * Interest invokers (`interestfor`) and the richer `role="dialog"` semantics come in later steps; this baseline opens on click alone.
 *
 * @element user-hovercard-basic
 * @cssprop [--hovercard-width=16rem] - The card's width.
 * @cssprop [--hovercard-gap=0.5rem] - The gap between the trigger and the card.
 * @cssprop [--hovercard-radius=0.875rem] - The card's corner radius.
 */
@customElement( 'user-hovercard-basic' )
export class UserHovercardBasic extends LitElement {

	/**
	 * The profile the card renders.
	 *
	 * It is a property, not a set of attributes, so a consumer binds one record (`.profile=${user}`) instead of wiring each field by hand.
	 * It is `attribute: false` because an object never round-trips through a string attribute, so there is no attribute to parse.
	 * It is optional with no default, because a person is product data the consumer owns; the component renders `nothing` when it is absent rather than inventing a fake person.
	 */
	@property( { attribute: false } )
	profile?: UserHovercardProfile;

	/**
	 * The card's preferred side before any edge flipping.
	 *
	 * Maps onto CSS `position-area`; the browser still flips to the opposite side when the preferred one would clip.
	 */
	@property( { type: String } )
	placement: HovercardPlacement = 'block-end';

	/**
	 * A unique suffix for this instance, so several cards on one page never collide.
	 *
	 * It backs both the DOM id (`popovertarget`) and the CSS anchor name, and a shared value would point every trigger at the first card.
	 * It comes from a module-level counter rather than `Math.random()`, so it cannot collide and stays stable across renders.
	 */
	private readonly uid = `hc-${ nextHovercardId++ }`;

	/**
	 * The placement, clamped to a value `position-area` accepts.
	 *
	 * `placement` is public, so a consumer can set it to anything; this getter falls back to `block-end` rather than letting an unknown string reach the inline style and produce an invalid `position-area`.
	 */
	private get safePlacement(): HovercardPlacement {
		return HOVERCARD_PLACEMENTS.has( this.placement ) ? this.placement : 'block-end';
	}

	/**
	 * Styles for the component, scoped to its shadow root.
	 *
	 * The anchor name is read from a custom property the template sets per instance, so the static stylesheet stays shared while each card anchors to its own trigger.
	 */
	static styles = css`
		:host {
			display: inline-block;
		}

		.trigger {
			anchor-name: var(--hc-anchor);
			font: inherit;
			font-weight: 600;
			color: var(--accent, #2257e6);
			background: none;
			border: none;
			padding: 0;
			cursor: pointer;
			border-radius: 0.25rem;
		}

		.trigger:hover {
			text-decoration: underline;
		}

		.trigger:focus-visible {
			outline: 2px solid var(--accent, #2257e6);
			outline-offset: 2px;
		}

		.card {
			position-anchor: var(--hc-anchor);
			position: fixed;
			position-area: var(--hc-area, block-end);
			margin: var(--hovercard-gap, 0.5rem);
			width: var(--hovercard-width, 16rem);
			max-width: calc(100vw - 2rem);
			box-sizing: border-box;
			inset: auto;
			border: 1px solid var(--line, #e7e6e2);
			border-radius: var(--hovercard-radius, 0.875rem);
			padding: 0.75rem;
			background: var(--surface, #fff);
			color: var(--ink, #17171a);
			text-align: start;
			box-shadow: 0 16px 40px rgb(0 0 0 / 0.18);
			position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
			opacity: 0;
			translate: 0 6px;
			transition:
				opacity 0.18s ease,
				translate 0.18s ease,
				display 0.18s ease allow-discrete,
				overlay 0.18s ease allow-discrete;
		}

		.card:popover-open {
			opacity: 1;
			translate: 0 0;
		}

		@starting-style {
			.card:popover-open {
				opacity: 0;
				translate: 0 6px;
			}
		}

		@media (prefers-reduced-motion: reduce) {
			.card {
				transition: none;
			}
		}

		.card-head {
			display: flex;
			align-items: flex-start;
			justify-content: space-between;
			gap: 0.5rem;
		}

		.name {
			margin: 0;
			font-size: 0.95rem;
			font-weight: 600;
			letter-spacing: -0.01em;
		}

		.handle {
			margin: 0.1rem 0 0;
			font-size: 0.8rem;
			color: var(--ink-faint, #71717b);
		}

		.bio {
			margin: 0.5rem 0 0;
			font-size: 0.85rem;
			line-height: 1.45;
			color: var(--ink-soft, #56565c);
		}

		.stat {
			margin: 0.5rem 0 0;
			font-size: 0.8rem;
			color: var(--ink-soft, #56565c);
		}

		.stat strong {
			color: var(--ink, #17171a);
		}

		.close {
			flex: none;
			width: 1.5rem;
			height: 1.5rem;
			display: grid;
			place-items: center;
			border-radius: 50%;
			border: 1px solid var(--line, #e7e6e2);
			background: var(--bg, #fcfcfb);
			color: var(--ink-soft, #56565c);
			font-size: 0.9rem;
			line-height: 1;
			cursor: pointer;
		}

		.close:focus-visible {
			outline: 2px solid var(--accent, #2257e6);
			outline-offset: 2px;
		}
	`;

	/**
	 * Renders the trigger and the card.
	 *
	 * The trigger carries `popovertarget` with an explicit `popovertargetaction="show"` so a click always opens (never toggles), the only trigger at this step.
	 * The card is a `popover` (auto), and an in-card Close button (`popovertargetaction="hide"`) gives it a visible close affordance beyond Esc and light dismiss.
	 */
	render() {
		if ( ! this.profile ) {
			return nothing;
		}

		const cardId = `${ this.uid }-card`;
		const styles = `--hc-anchor:--${ this.uid }; --hc-area:${ this.safePlacement };`;

		return html`
			<span style=${ styles }>
				<button
					type="button"
					class="trigger"
					popovertarget=${ cardId }
					popovertargetaction="show"
				>@${ this.profile.handle }</button>

				<div id=${ cardId } class="card" popover>
					<div class="card-head">
						<div>
							<h2 class="name">${ this.profile.name }</h2>
							<p class="handle">@${ this.profile.handle }</p>
						</div>
						<button
							type="button"
							class="close"
							popovertarget=${ cardId }
							popovertargetaction="hide"
							aria-label="Close ${ this.profile.name } preview"
						>&times;</button>
					</div>
					<p class="bio">${ this.profile.bio }</p>
					<p class="stat"><strong>${ this.formatFollowers( this.profile.followers ) }</strong> followers</p>
				</div>
			</span>
		`;
	}

	/**
	 * Formats a follower count into a short label, so `4200` reads as `4.2k`.
	 *
	 * @param count - The raw follower count.
	 * @returns The compact label.
	 */
	private formatFollowers( count: number ): string {
		if ( count < 1000 ) {
			return String( count );
		}

		return `${ ( count / 1000 ).toFixed( 1 ).replace( /\.0$/, '' ) }k`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'user-hovercard-basic': UserHovercardBasic;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

Step 5: the frontier, interestfor

So far a click opens the card; a hovercard should also open on hover and focus, and that’s the experimental layer. interestfor turns an element into an interest invoker. When the user shows interest, by hovering, focusing, or long-pressing on a touchscreen, the browser opens its target, and pressing Esc cancels. The word that matters is layer, because it sits alongside the popovertarget from step 1, it doesn’t replace it:

index.ts
// index.ts, inside render()
<button
	type="button"
	class="trigger"
	popovertarget=${ cardId }
	popovertargetaction="show"
	interestfor=${ cardId }
>@${ this.profile.handle }</button>

A browser that understands interestfor opens the card on interest. One that doesn’t simply ignores the attribute, and the popovertarget click still opens it, so nothing breaks where it’s missing. This is where the show from step 1 earns its place. Now that hover and focus can open the card, a default toggle click would close one that interest just opened; popovertargetaction="show" always opens, so the click never fights the hover.

Tune the feel with two CSS properties, interest-delay-start and interest-delay-end, so a quick pass over the handle doesn’t flash the card open and the pointer can travel from handle to card without it snapping shut:

index.ts
/* index.ts, inside static styles, on the trigger */
.trigger {
	interest-delay-start: 350ms;
	interest-delay-end: 300ms;
}

Now the honest part, and the reason this is step five and not step one. As of today interestfor ships unflagged in Chromium (Chrome and Edge since version 142, October 2025), and that’s the whole list. Firefox and Safari don’t have it, and WebKit has formally opposed it, citing the very problem we keep circling, that hover doesn’t exist on touch, so an interest-only trigger leaves touch users with no way in. The attribute is also still non-standard, in an open spec pull request. So treat it as a real enhancement where it exists, and lean on the step-1 click everywhere else.

Browser supportinterestfor70% of users
Chrome142
Edge142
FirefoxNo
SafariNo
Safari iOSNo
Chrome Android142

Browser data updated October 15, 2026

That table is the single most important caveat in the build, and why the click trigger from step 1 isn’t optional scaffolding. It’s what keeps the card reachable on most of the web.

Step 6: semantics, and the contract that catches people

This step is where a tooltip and a dialog part ways, and where most hovercards quietly get the accessibility wrong, because the card has a Follow button in it, a focusable control.

The instinct is to call a hover-shown bubble a tooltip and move on. But a tooltip, per the ARIA tooltip role, must be non-focusable and text-only. The ARIA Authoring Practices say it directly, that a hover containing focusable elements should be a non-modal dialog, not a tooltip. So a card with a Follow button is misclassified the moment you call it one.

The platform accounts for this. When an interest invoker opens a popover whose contents are richer than plain text (the Open UI interest invokers explainer calls it a rich hint), Chromium applies a minimum role=tooltip, sets aria-expanded on the invoker, creates an aria-details relationship from the invoker to the card, and orders sequential focus so the open card is the next Tab stop after the handle. Minimum means it only applies when you haven’t set a stronger role yourself, and that’s the hook. Declare the card a non-modal dialog, and your role wins:

index.ts
// index.ts, inside render()
<div id=${ cardId } class="card" popover role="dialog" aria-labelledby=${ titleId }>
	<button
		type="button"
		class="close"
		popovertarget=${ cardId }
		popovertargetaction="hide"
		aria-label="Close ${ this.profile.name } preview"
	>&times;</button>
	<h2 id=${ titleId } class="name">${ this.profile.name }</h2>
	<!-- bio and stat -->
	<button type="button" class="follow" aria-pressed=${ this.profile.following } @click=${ this.toggleFollow }>
		${ this.profile.following ? 'Following' : 'Follow' }
	</button>
</div>

role="dialog" plus an accessible name from aria-labelledby overrides the tooltip minimum. Notice what we leave off on purpose, the aria-modal and the inert page, neither of which we add. That puts the card closer to a non-modal dialog than a tooltip. It has an accessible name and focusable controls, and it does not set aria-modal or inert the page. The role still does not make the accessibility story automatic, because role="dialog" alone is not enough. The live component has to prove the keyboard route and screen-reader announcement.

One limit is worth stating plainly. Keyboard reachability comes from the interest-invoker rich-hint mapping, which orders sequential focus so the open card is the next Tab stop. The thing to check in DevTools is that Tab from the handle moves into the card, with Close and Follow reachable next.

That focus ordering is part of the interest-invoker behavior, so on Firefox and Safari, where interestfor does nothing, it isn’t there either, and the click from step 1 is what opens the card. The fallback is more than just a click, though, because the base Popover API’s popovertarget relationship still wires the trigger to the popover as its invoker, which puts the open card next in the tab order and gives you the implicit aria-details and aria-expanded relationship even without the rich-hint mapping.

Whether a screen reader announces the rich card the way you’d hope is shakier, because the rich-hint mapping leans on aria-details, whose assistive-tech support has historically been uneven. Verify the announcement in a real screen reader before you promise it.

The follow handler dispatches a typed event and leaves the card open. In this demo the component also flips its own following flag so the button updates on click, while a real app would let the parent own the source of truth and treat the event as the report:

index.ts
// index.ts, a new member on the class

/**
 * Toggles the follow state and reports it through the typed `hovercard-follow` event.
 *
 * The card stays open after the click, because following someone is not a reason to dismiss the preview, so the close paths (the Close button, Esc, and light dismiss) stay the only way out.
 * The event is `composed` so it crosses the shadow boundary, and `bubbles` so a listener on the host receives it.
 */
private toggleFollow = (): void => {
	if ( ! this.profile ) {
		return;
	}

	const following = ! this.profile.following;
	this.profile = { ...this.profile, following };

	this.dispatchEvent(
		new CustomEvent<HovercardFollowDetail>( 'hovercard-follow', {
			detail: { handle: this.profile.handle, following },
			bubbles: true,
			composed: true,
		} ),
	);
};

The four rules this build settled on, in one place:

  • Use popover (auto) for the actionable card, not popover="hint". A hint lives in its own stack and is meant for non-click hover previews, so clicking a button to open one has odd consequences. Save hint for a preview that carries no actions.
  • Treat interestfor as a layer, never a swap. Keep popovertarget on the element so the click opening always exists.
  • State the fallback concretely before teaching the API. Here it’s the click. Don’t claim a click fallback unless the click-opening markup is present.
  • Make the click an explicit show, with a real in-card Close. The default toggle fights the hover path, and Esc alone is a thin close affordance.

Step 7: put it together

That is the whole component. The assembled types.ts holds the profile shape, the placement type, and the event types so a listener is typed without a cast:

types.ts
// types.ts

/**
 * The profile data a `user-hovercard` renders.
 *
 * It is a plain data shape rather than a set of separate attributes, so a consumer can pass a whole record with one property binding instead of wiring six attributes by hand.
 */
export interface UserHovercardProfile {

	/**
	 * The account handle shown on the trigger, without the leading `@`.
	 * The component adds the `@` itself, so a caller passes `agustin`, not `@agustin`.
	 */
	handle: string;

	/**
	 * The person's display name, shown as the card's heading and used as its accessible name.
	 */
	name: string;

	/**
	 * A one-line bio shown under the name.
	 */
	bio: string;

	/**
	 * The follower count, shown as a stat on the card.
	 */
	followers: number;

	/**
	 * Whether the viewer already follows this account.
	 * A data field on the profile; later steps read it to label and press the Follow control once the card has one.
	 */
	following: boolean;
}

/**
 * Where the card sits relative to its trigger before any edge flipping.
 *
 * These map onto the CSS `position-area` grid: `block-end` is below the trigger, `block-start` is above it.
 * The browser still flips to the opposite side when the preferred side would clip, so this is a preference, not a guarantee.
 */
export type HovercardPlacement = 'block-end' | 'block-start' | 'inline-end' | 'inline-start';

/**
 * The detail payload carried by the `hovercard-follow` event of `user-hovercard`.
 */
export interface HovercardFollowDetail {

	/**
	 * The handle the viewer acted on, so a single listener high in the tree can tell cards apart.
	 */
	handle: string;

	/**
	 * The follow state after the click: `true` when the viewer just followed, `false` when they unfollowed.
	 */
	following: boolean;
}

/**
 * Event map for `user-hovercard`, so a listener on the element is typed without a cast.
 */
export interface UserHovercardEventMap extends HTMLElementEventMap {
	'hovercard-follow': CustomEvent<HovercardFollowDetail>;
}

The assembled component takes shape below. This article version condenses the comments and elides the full static styles body to keep the lesson readable, so copy the full component file from the folded block that follows rather than this one. The component file is user-hovercard.ts (the same one the live demo’s source panel shows), and it imports ./types, so it isn’t standalone; copy it alongside the types.ts shown above to get both files.

index.ts
// index.ts
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { HovercardFollowDetail, HovercardPlacement, UserHovercardProfile, UserHovercardEventMap } from './types';

/**
 * A running id for hovercard instances, so each one mints a stable, unique suffix that cannot collide.
 */
let nextHovercardId = 0;

/**
 * The placements `position-area` accepts, used to clamp the public `placement` before it reaches the style string.
 */
const HOVERCARD_PLACEMENTS = new Set<HovercardPlacement>( [
	'block-end',
	'block-start',
	'inline-end',
	'inline-start',
] );

/**
 * A username hovercard that opens a small profile card next to a handle.
 *
 * The trigger is a real `<button>` invoker, the card is a `popover` (auto) declared as a non-modal `role="dialog"` with an accessible name, and placement and edge flipping are CSS anchor positioning, so there is no positioning JavaScript.
 * The click opening (`popovertarget` with `popovertargetaction="show"`) always stays in the markup, and `interestfor` layers hover, focus, and long-press on top where the browser supports it.
 *
 * @element user-hovercard
 * @fires hovercard-follow - Fired when the viewer clicks Follow, carrying a `HovercardFollowDetail`.
 * @cssprop [--hovercard-width=16rem] - The card's width.
 * @cssprop [--hovercard-gap=0.5rem] - The gap between the trigger and the card.
 * @cssprop [--hovercard-radius=0.875rem] - The card's corner radius.
 * @attr reduced-motion - Demo affordance: forces the open transition off on top of the system setting, since a sandboxed iframe cannot force the real media query.
 */
@customElement( 'user-hovercard' )
export class UserHovercard extends LitElement {

	/**
	 * The profile the card renders.
	 *
	 * It is `attribute: false` because an object binds as a property (`.profile=${user}`) and never round-trips through a string attribute.
	 * It has no default, since a default person would be invented product data; the parent supplies the record, and the card renders nothing until it does.
	 */
	@property( { attribute: false } )
	profile?: UserHovercardProfile;

	/**
	 * The card's preferred side before any edge flipping, mapped onto CSS `position-area`.
	 */
	@property( { type: String } )
	placement: HovercardPlacement = 'block-end';

	/**
	 * The delay, in milliseconds, before the card opens on interest, driving `interest-delay-start`.
	 */
	@property( { type: Number, attribute: 'interest-delay-start' } )
	interestDelayStart = 350;

	/**
	 * The delay, in milliseconds, before the card closes on losing interest, driving `interest-delay-end`.
	 */
	@property( { type: Number, attribute: 'interest-delay-end' } )
	interestDelayEnd = 300;

	/**
	 * Forces the open transition off beyond the system setting; a live-example affordance, not needed in production.
	 */
	@property( { type: Boolean, reflect: true, attribute: 'reduced-motion' } )
	reducedMotion = false;

	/**
	 * A unique suffix for this instance, backing both the DOM ids and the CSS anchor name so several cards never collide.
	 *
	 * It comes from a module-level counter rather than `Math.random()`, so it cannot collide and stays stable across renders.
	 */
	private readonly uid = `hc-${ nextHovercardId++ }`;

	/**
	 * The component styles, scoped to the shadow root.
	 *
	 * The full body lives in the demo's source panel below: the `:host` and card layout, the `:popover-open` open and close animation with `@starting-style` and the reduced-motion opt-out, and the `position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline` from step 3.
	 * The anchor name and the `interest-delay` values read from per-instance custom properties the template sets, so the stylesheet stays shared while each card anchors to its own trigger.
	 */
	static styles = css`
		/* See the demo's source panel below for the full stylesheet. */
	`;

	/**
	 * The placement, clamped to a value `position-area` accepts, falling back to `block-end`.
	 */
	private get safePlacement(): HovercardPlacement {
		return HOVERCARD_PLACEMENTS.has( this.placement ) ? this.placement : 'block-end';
	}

	/**
	 * Clamps a public delay to a non-negative, finite millisecond value before it reaches the style string.
	 *
	 * @param value - The raw delay from the public property.
	 * @param fallback - The delay to use when the value is not a finite number.
	 * @returns A non-negative millisecond value.
	 */
	private clampDelay( value: number, fallback: number ): number {
		return Math.max( 0, Number.isFinite( value ) ? value : fallback );
	}

	/**
	 * Types `addEventListener( 'hovercard-follow', ... )` so the listener receives a `CustomEvent<HovercardFollowDetail>` with no cast.
	 */
	addEventListener<K extends keyof UserHovercardEventMap>(
		type: K,
		listener: ( this: UserHovercard, ev: UserHovercardEventMap[ K ] ) => void,
		options?: boolean | AddEventListenerOptions,
	): void;
	addEventListener(
		type: string,
		listener: EventListenerOrEventListenerObject,
		options?: boolean | AddEventListenerOptions,
	): void {
		super.addEventListener( type, listener, options );
	}

	/**
	 * Renders the trigger and the card.
	 *
	 * The trigger carries `popovertarget` with `popovertargetaction="show"` so a click always opens, plus `interestfor` for hover, focus, and long-press where supported; the public placement and delays are clamped before they reach the inline style.
	 */
	render() {
		if ( ! this.profile ) {
			return nothing;
		}

		const cardId = `${ this.uid }-card`;
		const titleId = `${ this.uid }-title`;
		const delayStart = this.clampDelay( this.interestDelayStart, 350 );
		const delayEnd = this.clampDelay( this.interestDelayEnd, 300 );
		const styles = `--hc-anchor:--${ this.uid }; --hc-area:${ this.safePlacement }; --hc-delay-start:${ delayStart }ms; --hc-delay-end:${ delayEnd }ms;`;

		return html`
			<span style=${ styles }>
				<button
					type="button"
					class="trigger"
					popovertarget=${ cardId }
					popovertargetaction="show"
					interestfor=${ cardId }
				>@${ this.profile.handle }</button>

				<div id=${ cardId } class="card" popover role="dialog" aria-labelledby=${ titleId }>
					<div class="card-head">
						<div>
							<h2 id=${ titleId } class="name">${ this.profile.name }</h2>
							<p class="handle">@${ this.profile.handle }</p>
						</div>
						<button
							type="button"
							class="close"
							popovertarget=${ cardId }
							popovertargetaction="hide"
							aria-label="Close ${ this.profile.name } preview"
						>&times;</button>
					</div>
					<p class="bio">${ this.profile.bio }</p>
					<p class="stat"><strong>${ this.formatFollowers( this.profile.followers ) }</strong> followers</p>
					<button type="button" class="follow" aria-pressed=${ this.profile.following } @click=${ this.toggleFollow }>
						${ this.profile.following ? 'Following' : 'Follow' }
					</button>
				</div>
			</span>
		`;
	}

	/**
	 * Toggles the follow state and reports it through the typed `hovercard-follow` event.
	 *
	 * The card stays open after the click, since following someone is not a reason to dismiss the preview; the event is `composed` and `bubbles` so a listener on the host hears it.
	 * It returns early when `profile` is absent, since the Follow button only renders with a profile, so this guards the optional record and narrows it for the reads below.
	 */
	private toggleFollow = (): void => {
		if ( ! this.profile ) {
			return;
		}

		const following = ! this.profile.following;
		this.profile = { ...this.profile, following };

		this.dispatchEvent(
			new CustomEvent<HovercardFollowDetail>( 'hovercard-follow', {
				detail: { handle: this.profile.handle, following },
				bubbles: true,
				composed: true,
			} ),
		);
	};

	/**
	 * Formats a follower count into a short label, so `4200` reads as `4.2k`.
	 *
	 * @param count - The raw follower count.
	 * @returns The compact label.
	 */
	private formatFollowers( count: number ): string {
		if ( count < 1000 ) {
			return String( count );
		}

		return `${ ( count / 1000 ).toFixed( 1 ).replace( /\.0$/, '' ) }k`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'user-hovercard': UserHovercard;
	}
}

The full component file, with every comment and the complete static styles body, is folded below. Expand it to read or copy the real user-hovercard.ts, the same source the demo’s panel shows.

user-hovercard.ts
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { HovercardFollowDetail, HovercardPlacement, UserHovercardProfile, UserHovercardEventMap } from './types';

/**
 * A running id for hovercard instances, so each one mints a stable, unique suffix.
 *
 * A module-level counter beats `Math.random()` here: it cannot collide, and it reads the same on every render.
 */
let nextHovercardId = 0;

/**
 * The placements `position-area` accepts, used to clamp the public `placement` before it reaches the style string.
 */
const HOVERCARD_PLACEMENTS = new Set<HovercardPlacement>( [
	'block-end',
	'block-start',
	'inline-end',
	'inline-start',
] );

/**
 * A username hovercard that opens a small profile card next to a handle.
 *
 * The trigger is a real `<button>` invoker, because opening a card in place is a UI control, not navigation.
 * The card is a `popover` (auto) declared as a non-modal `role="dialog"` with an accessible name, since it holds a focusable Follow button and so is interactive content, not a tooltip.
 * Placement and edge flipping are CSS anchor positioning, so there is no positioning JavaScript.
 * The click opening (`popovertarget` with `popovertargetaction="show"`) always stays in the markup, and `interestfor` layers hover, focus, and long-press on top where the browser supports it.
 *
 * @element user-hovercard
 * @fires hovercard-follow - Fired when the viewer clicks Follow, carrying a `HovercardFollowDetail`.
 * @cssprop [--hovercard-width=16rem] - The card's width.
 * @cssprop [--hovercard-gap=0.5rem] - The gap between the trigger and the card.
 * @cssprop [--hovercard-radius=0.875rem] - The card's corner radius.
 * @attr reduced-motion - Demo affordance: forces the open transition off on top of the system `prefers-reduced-motion` setting, since a sandboxed iframe cannot force the real media query.
 */
@customElement( 'user-hovercard' )
export class UserHovercard extends LitElement {

	/**
	 * The profile the card renders.
	 *
	 * It is a property, not a set of attributes, so a consumer binds one record (`.profile=${user}`) instead of wiring each field by hand.
	 * It is `attribute: false` because an object never round-trips through a string attribute, so there is no attribute to parse.
	 * It is optional with no default, because a person is product data the consumer owns; the component renders `nothing` when it is absent rather than inventing a fake person.
	 */
	@property( { attribute: false } )
	profile?: UserHovercardProfile;

	/**
	 * The card's preferred side before any edge flipping.
	 *
	 * Maps onto CSS `position-area`; the browser still flips to the opposite side when the preferred one would clip.
	 */
	@property( { type: String } )
	placement: HovercardPlacement = 'block-end';

	/**
	 * The delay, in milliseconds, between the viewer showing interest (hover or focus) and the card opening.
	 *
	 * Drives the `interest-delay-start` CSS property, so a quick pass over the handle does not flash the card open.
	 */
	@property( { type: Number, attribute: 'interest-delay-start' } )
	interestDelayStart = 350;

	/**
	 * The delay, in milliseconds, between the viewer losing interest and the card closing.
	 *
	 * Drives the `interest-delay-end` CSS property, so the pointer can travel from the handle to the card without it snapping shut on the way.
	 */
	@property( { type: Number, attribute: 'interest-delay-end' } )
	interestDelayEnd = 300;

	/**
	 * Forces the open transition off, beyond the system `prefers-reduced-motion` setting.
	 *
	 * This is a live-example affordance, because a sandboxed iframe cannot force the real `prefers-reduced-motion` media query.
	 * Production code does not need it, since the `@media (prefers-reduced-motion: reduce)` rule already covers the real setting.
	 */
	@property( { type: Boolean, reflect: true, attribute: 'reduced-motion' } )
	reducedMotion = false;

	/**
	 * A unique suffix for this instance, so several cards on one page never collide.
	 *
	 * It backs both the DOM ids (`popovertarget`, `interestfor`, `aria-labelledby`) and the CSS anchor name, and a shared value would point every trigger at the first card.
	 * It comes from a module-level counter rather than `Math.random()`, so it cannot collide and stays stable across renders.
	 */
	private readonly uid = `hc-${ nextHovercardId++ }`;

	/**
	 * The placement, clamped to a value `position-area` accepts.
	 *
	 * `placement` is public, so a consumer can set it to anything; this getter falls back to `block-end` rather than letting an unknown string reach the inline style and produce an invalid `position-area`.
	 */
	private get safePlacement(): HovercardPlacement {
		return HOVERCARD_PLACEMENTS.has( this.placement ) ? this.placement : 'block-end';
	}

	/**
	 * Clamps a public delay to a non-negative, finite millisecond value before it reaches the style string.
	 *
	 * The delays are public numbers, so a consumer can pass `NaN`, a negative, or `Infinity`; this guards the inline `interest-delay-*` value against all three.
	 *
	 * @param value - The raw delay from the public property.
	 * @param fallback - The delay to use when the value is not a finite number.
	 * @returns A non-negative millisecond value.
	 */
	private clampDelay( value: number, fallback: number ): number {
		return Math.max( 0, Number.isFinite( value ) ? value : fallback );
	}

	/**
	 * Styles for the component, scoped to its shadow root.
	 *
	 * The anchor name and the `interest-delay` values are read from custom properties the template sets per instance, so the static stylesheet stays shared while each card anchors to its own trigger and uses its own delays.
	 */
	static styles = css`
		:host {
			display: inline-block;
		}

		.trigger {
			anchor-name: var(--hc-anchor);
			interest-delay-start: var(--hc-delay-start, 350ms);
			interest-delay-end: var(--hc-delay-end, 300ms);
			font: inherit;
			font-weight: 600;
			color: var(--accent, #2257e6);
			background: none;
			border: none;
			padding: 0;
			cursor: pointer;
			border-radius: 0.25rem;
		}

		.trigger:hover {
			text-decoration: underline;
		}

		.trigger:focus-visible {
			outline: 2px solid var(--accent, #2257e6);
			outline-offset: 2px;
		}

		.card {
			position-anchor: var(--hc-anchor);
			position: fixed;
			position-area: var(--hc-area, block-end);
			margin: var(--hovercard-gap, 0.5rem);
			width: var(--hovercard-width, 16rem);
			max-width: calc(100vw - 2rem);
			box-sizing: border-box;
			inset: auto;
			border: 1px solid var(--line, #e7e6e2);
			border-radius: var(--hovercard-radius, 0.875rem);
			padding: 0.75rem;
			background: var(--surface, #fff);
			color: var(--ink, #17171a);
			text-align: start;
			box-shadow: 0 16px 40px rgb(0 0 0 / 0.18);
			position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
			opacity: 0;
			translate: 0 6px;
			transition:
				opacity 0.18s ease,
				translate 0.18s ease,
				display 0.18s ease allow-discrete,
				overlay 0.18s ease allow-discrete;
		}

		.card:popover-open {
			opacity: 1;
			translate: 0 0;
		}

		@starting-style {
			.card:popover-open {
				opacity: 0;
				translate: 0 6px;
			}
		}

		:host([reduced-motion]) .card {
			transition: none;
		}

		@media (prefers-reduced-motion: reduce) {
			.card {
				transition: none;
			}
		}

		.card-head {
			display: flex;
			align-items: flex-start;
			justify-content: space-between;
			gap: 0.5rem;
		}

		.name {
			margin: 0;
			font-size: 0.95rem;
			font-weight: 600;
			letter-spacing: -0.01em;
		}

		.handle {
			margin: 0.1rem 0 0;
			font-size: 0.8rem;
			color: var(--ink-faint, #71717b);
		}

		.bio {
			margin: 0.5rem 0 0;
			font-size: 0.85rem;
			line-height: 1.45;
			color: var(--ink-soft, #56565c);
		}

		.stat {
			margin: 0.5rem 0 0;
			font-size: 0.8rem;
			color: var(--ink-soft, #56565c);
		}

		.stat strong {
			color: var(--ink, #17171a);
		}

		.close {
			flex: none;
			width: 1.5rem;
			height: 1.5rem;
			display: grid;
			place-items: center;
			border-radius: 50%;
			border: 1px solid var(--line, #e7e6e2);
			background: var(--bg, #fcfcfb);
			color: var(--ink-soft, #56565c);
			font-size: 0.9rem;
			line-height: 1;
			cursor: pointer;
		}

		.follow {
			margin-top: 0.625rem;
			width: 100%;
			padding: 0.4rem 0.75rem;
			border-radius: 0.5rem;
			border: 1px solid var(--accent, #2257e6);
			background: var(--accent, #2257e6);
			color: #fff;
			font: inherit;
			font-size: 0.85rem;
			font-weight: 600;
			cursor: pointer;
		}

		.follow[aria-pressed='true'] {
			background: transparent;
			color: var(--accent, #2257e6);
		}

		.follow:focus-visible,
		.close:focus-visible {
			outline: 2px solid var(--accent, #2257e6);
			outline-offset: 2px;
		}
	`;

	/**
	 * Types `addEventListener( 'hovercard-follow', ... )` so the listener receives a `CustomEvent<HovercardFollowDetail>` with no cast.
	 * Falls through to the standard signature for every other event.
	 */
	addEventListener<K extends keyof UserHovercardEventMap>(
		type: K,
		listener: ( this: UserHovercard, ev: UserHovercardEventMap[ K ] ) => void,
		options?: boolean | AddEventListenerOptions,
	): void;
	addEventListener(
		type: string,
		listener: EventListenerOrEventListenerObject,
		options?: boolean | AddEventListenerOptions,
	): void {
		super.addEventListener( type, listener, options );
	}

	/**
	 * Renders the trigger and the card.
	 *
	 * The trigger carries `popovertarget` with an explicit `popovertargetaction="show"` so a click always opens (never toggles a card that interest already opened), plus `interestfor` so hover, focus, and long-press also open it where supported.
	 * The card is a `popover` (auto) with `role="dialog"` and an accessible name, and an in-card Close button (`popovertargetaction="hide"`) gives it a real close affordance beyond Esc and light dismiss.
	 */
	render() {
		if ( ! this.profile ) {
			return nothing;
		}

		const cardId = `${ this.uid }-card`;
		const titleId = `${ this.uid }-title`;
		const delayStart = this.clampDelay( this.interestDelayStart, 350 );
		const delayEnd = this.clampDelay( this.interestDelayEnd, 300 );
		const styles = `--hc-anchor:--${ this.uid }; --hc-area:${ this.safePlacement }; --hc-delay-start:${ delayStart }ms; --hc-delay-end:${ delayEnd }ms;`;

		return html`
			<span style=${ styles }>
				<button
					type="button"
					class="trigger"
					popovertarget=${ cardId }
					popovertargetaction="show"
					interestfor=${ cardId }
				>@${ this.profile.handle }</button>

				<div id=${ cardId } class="card" popover role="dialog" aria-labelledby=${ titleId }>
					<div class="card-head">
						<div>
							<h2 id=${ titleId } class="name">${ this.profile.name }</h2>
							<p class="handle">@${ this.profile.handle }</p>
						</div>
						<button
							type="button"
							class="close"
							popovertarget=${ cardId }
							popovertargetaction="hide"
							aria-label="Close ${ this.profile.name } preview"
						>&times;</button>
					</div>
					<p class="bio">${ this.profile.bio }</p>
					<p class="stat"><strong>${ this.formatFollowers( this.profile.followers ) }</strong> followers</p>
					<button type="button" class="follow" aria-pressed=${ this.profile.following } @click=${ this.toggleFollow }>
						${ this.profile.following ? 'Following' : 'Follow' }
					</button>
				</div>
			</span>
		`;
	}

	/**
	 * Toggles the follow state and reports it through the typed `hovercard-follow` event.
	 *
	 * The card stays open after the click, because following someone is not a reason to dismiss the preview, so the close paths (the Close button, Esc, and light dismiss) stay the only way out.
	 * The event is `composed` so it crosses the shadow boundary, and `bubbles` so a listener on the host receives it.
	 * It returns early when `profile` is absent, since the Follow button only renders with a profile, so this guards the optional record and narrows it for the reads below.
	 */
	private toggleFollow = (): void => {
		if ( ! this.profile ) {
			return;
		}

		const following = ! this.profile.following;
		this.profile = { ...this.profile, following };

		this.dispatchEvent(
			new CustomEvent<HovercardFollowDetail>( 'hovercard-follow', {
				detail: { handle: this.profile.handle, following },
				bubbles: true,
				composed: true,
			} ),
		);
	};

	/**
	 * Formats a follower count into a short label, so `4200` reads as `4.2k`.
	 *
	 * @param count - The raw follower count.
	 * @returns The compact label.
	 */
	private formatFollowers( count: number ): string {
		if ( count < 1000 ) {
			return String( count );
		}

		return `${ ( count / 1000 ).toFixed( 1 ).replace( /\.0$/, '' ) }k`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'user-hovercard': UserHovercard;
	}
}

The component ships with no profile, so the parent owns the record and binds it in. That’s the contract the demo harness models, holding the sample person as its own field and passing it down with .profile:

demo-harness.ts
// demo-harness.ts
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import './index';
import type { UserHovercardProfile } from './types';

/**
 * A small consumer that owns the profile data and feeds one `user-hovercard`.
 *
 * The sample person lives here, on the parent, not inside the component, so the lesson stays visible: the card renders nothing until a parent provides `.profile`.
 */
@customElement( 'hovercard-demo' )
export class HovercardDemo extends LitElement {

	/**
	 * The profile the harness owns and passes down; the component never invents this.
	 */
	private readonly profile: UserHovercardProfile = {
		handle: 'agustin',
		name: 'Agustin Barrientos',
		bio: 'Frontend lead. Writes The Senior Eye.',
		followers: 4200,
		following: false,
	};

	render() {
		return html`<user-hovercard .profile=${ this.profile }></user-hovercard>`;
	}
}

Try the finished component. Hover, focus, or long-press the handle where the browser supports interest invokers, and click opens it anywhere the base Popover API is supported. Tab from the handle and you reach Close, then Follow. Tune the interest delays, change the placement, and flip on reduced motion to feel the difference.

user-hovercard
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { HovercardFollowDetail, HovercardPlacement, UserHovercardProfile, UserHovercardEventMap } from './types';

/**
 * A running id for hovercard instances, so each one mints a stable, unique suffix.
 *
 * A module-level counter beats `Math.random()` here: it cannot collide, and it reads the same on every render.
 */
let nextHovercardId = 0;

/**
 * The placements `position-area` accepts, used to clamp the public `placement` before it reaches the style string.
 */
const HOVERCARD_PLACEMENTS = new Set<HovercardPlacement>( [
	'block-end',
	'block-start',
	'inline-end',
	'inline-start',
] );

/**
 * A username hovercard that opens a small profile card next to a handle.
 *
 * The trigger is a real `<button>` invoker, because opening a card in place is a UI control, not navigation.
 * The card is a `popover` (auto) declared as a non-modal `role="dialog"` with an accessible name, since it holds a focusable Follow button and so is interactive content, not a tooltip.
 * Placement and edge flipping are CSS anchor positioning, so there is no positioning JavaScript.
 * The click opening (`popovertarget` with `popovertargetaction="show"`) always stays in the markup, and `interestfor` layers hover, focus, and long-press on top where the browser supports it.
 *
 * @element user-hovercard
 * @fires hovercard-follow - Fired when the viewer clicks Follow, carrying a `HovercardFollowDetail`.
 * @cssprop [--hovercard-width=16rem] - The card's width.
 * @cssprop [--hovercard-gap=0.5rem] - The gap between the trigger and the card.
 * @cssprop [--hovercard-radius=0.875rem] - The card's corner radius.
 * @attr reduced-motion - Demo affordance: forces the open transition off on top of the system `prefers-reduced-motion` setting, since a sandboxed iframe cannot force the real media query.
 */
@customElement( 'user-hovercard' )
export class UserHovercard extends LitElement {

	/**
	 * The profile the card renders.
	 *
	 * It is a property, not a set of attributes, so a consumer binds one record (`.profile=${user}`) instead of wiring each field by hand.
	 * It is `attribute: false` because an object never round-trips through a string attribute, so there is no attribute to parse.
	 * It is optional with no default, because a person is product data the consumer owns; the component renders `nothing` when it is absent rather than inventing a fake person.
	 */
	@property( { attribute: false } )
	profile?: UserHovercardProfile;

	/**
	 * The card's preferred side before any edge flipping.
	 *
	 * Maps onto CSS `position-area`; the browser still flips to the opposite side when the preferred one would clip.
	 */
	@property( { type: String } )
	placement: HovercardPlacement = 'block-end';

	/**
	 * The delay, in milliseconds, between the viewer showing interest (hover or focus) and the card opening.
	 *
	 * Drives the `interest-delay-start` CSS property, so a quick pass over the handle does not flash the card open.
	 */
	@property( { type: Number, attribute: 'interest-delay-start' } )
	interestDelayStart = 350;

	/**
	 * The delay, in milliseconds, between the viewer losing interest and the card closing.
	 *
	 * Drives the `interest-delay-end` CSS property, so the pointer can travel from the handle to the card without it snapping shut on the way.
	 */
	@property( { type: Number, attribute: 'interest-delay-end' } )
	interestDelayEnd = 300;

	/**
	 * Forces the open transition off, beyond the system `prefers-reduced-motion` setting.
	 *
	 * This is a live-example affordance, because a sandboxed iframe cannot force the real `prefers-reduced-motion` media query.
	 * Production code does not need it, since the `@media (prefers-reduced-motion: reduce)` rule already covers the real setting.
	 */
	@property( { type: Boolean, reflect: true, attribute: 'reduced-motion' } )
	reducedMotion = false;

	/**
	 * A unique suffix for this instance, so several cards on one page never collide.
	 *
	 * It backs both the DOM ids (`popovertarget`, `interestfor`, `aria-labelledby`) and the CSS anchor name, and a shared value would point every trigger at the first card.
	 * It comes from a module-level counter rather than `Math.random()`, so it cannot collide and stays stable across renders.
	 */
	private readonly uid = `hc-${ nextHovercardId++ }`;

	/**
	 * The placement, clamped to a value `position-area` accepts.
	 *
	 * `placement` is public, so a consumer can set it to anything; this getter falls back to `block-end` rather than letting an unknown string reach the inline style and produce an invalid `position-area`.
	 */
	private get safePlacement(): HovercardPlacement {
		return HOVERCARD_PLACEMENTS.has( this.placement ) ? this.placement : 'block-end';
	}

	/**
	 * Clamps a public delay to a non-negative, finite millisecond value before it reaches the style string.
	 *
	 * The delays are public numbers, so a consumer can pass `NaN`, a negative, or `Infinity`; this guards the inline `interest-delay-*` value against all three.
	 *
	 * @param value - The raw delay from the public property.
	 * @param fallback - The delay to use when the value is not a finite number.
	 * @returns A non-negative millisecond value.
	 */
	private clampDelay( value: number, fallback: number ): number {
		return Math.max( 0, Number.isFinite( value ) ? value : fallback );
	}

	/**
	 * Styles for the component, scoped to its shadow root.
	 *
	 * The anchor name and the `interest-delay` values are read from custom properties the template sets per instance, so the static stylesheet stays shared while each card anchors to its own trigger and uses its own delays.
	 */
	static styles = css`
		:host {
			display: inline-block;
		}

		.trigger {
			anchor-name: var(--hc-anchor);
			interest-delay-start: var(--hc-delay-start, 350ms);
			interest-delay-end: var(--hc-delay-end, 300ms);
			font: inherit;
			font-weight: 600;
			color: var(--accent, #2257e6);
			background: none;
			border: none;
			padding: 0;
			cursor: pointer;
			border-radius: 0.25rem;
		}

		.trigger:hover {
			text-decoration: underline;
		}

		.trigger:focus-visible {
			outline: 2px solid var(--accent, #2257e6);
			outline-offset: 2px;
		}

		.card {
			position-anchor: var(--hc-anchor);
			position: fixed;
			position-area: var(--hc-area, block-end);
			margin: var(--hovercard-gap, 0.5rem);
			width: var(--hovercard-width, 16rem);
			max-width: calc(100vw - 2rem);
			box-sizing: border-box;
			inset: auto;
			border: 1px solid var(--line, #e7e6e2);
			border-radius: var(--hovercard-radius, 0.875rem);
			padding: 0.75rem;
			background: var(--surface, #fff);
			color: var(--ink, #17171a);
			text-align: start;
			box-shadow: 0 16px 40px rgb(0 0 0 / 0.18);
			position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
			opacity: 0;
			translate: 0 6px;
			transition:
				opacity 0.18s ease,
				translate 0.18s ease,
				display 0.18s ease allow-discrete,
				overlay 0.18s ease allow-discrete;
		}

		.card:popover-open {
			opacity: 1;
			translate: 0 0;
		}

		@starting-style {
			.card:popover-open {
				opacity: 0;
				translate: 0 6px;
			}
		}

		:host([reduced-motion]) .card {
			transition: none;
		}

		@media (prefers-reduced-motion: reduce) {
			.card {
				transition: none;
			}
		}

		.card-head {
			display: flex;
			align-items: flex-start;
			justify-content: space-between;
			gap: 0.5rem;
		}

		.name {
			margin: 0;
			font-size: 0.95rem;
			font-weight: 600;
			letter-spacing: -0.01em;
		}

		.handle {
			margin: 0.1rem 0 0;
			font-size: 0.8rem;
			color: var(--ink-faint, #71717b);
		}

		.bio {
			margin: 0.5rem 0 0;
			font-size: 0.85rem;
			line-height: 1.45;
			color: var(--ink-soft, #56565c);
		}

		.stat {
			margin: 0.5rem 0 0;
			font-size: 0.8rem;
			color: var(--ink-soft, #56565c);
		}

		.stat strong {
			color: var(--ink, #17171a);
		}

		.close {
			flex: none;
			width: 1.5rem;
			height: 1.5rem;
			display: grid;
			place-items: center;
			border-radius: 50%;
			border: 1px solid var(--line, #e7e6e2);
			background: var(--bg, #fcfcfb);
			color: var(--ink-soft, #56565c);
			font-size: 0.9rem;
			line-height: 1;
			cursor: pointer;
		}

		.follow {
			margin-top: 0.625rem;
			width: 100%;
			padding: 0.4rem 0.75rem;
			border-radius: 0.5rem;
			border: 1px solid var(--accent, #2257e6);
			background: var(--accent, #2257e6);
			color: #fff;
			font: inherit;
			font-size: 0.85rem;
			font-weight: 600;
			cursor: pointer;
		}

		.follow[aria-pressed='true'] {
			background: transparent;
			color: var(--accent, #2257e6);
		}

		.follow:focus-visible,
		.close:focus-visible {
			outline: 2px solid var(--accent, #2257e6);
			outline-offset: 2px;
		}
	`;

	/**
	 * Types `addEventListener( 'hovercard-follow', ... )` so the listener receives a `CustomEvent<HovercardFollowDetail>` with no cast.
	 * Falls through to the standard signature for every other event.
	 */
	addEventListener<K extends keyof UserHovercardEventMap>(
		type: K,
		listener: ( this: UserHovercard, ev: UserHovercardEventMap[ K ] ) => void,
		options?: boolean | AddEventListenerOptions,
	): void;
	addEventListener(
		type: string,
		listener: EventListenerOrEventListenerObject,
		options?: boolean | AddEventListenerOptions,
	): void {
		super.addEventListener( type, listener, options );
	}

	/**
	 * Renders the trigger and the card.
	 *
	 * The trigger carries `popovertarget` with an explicit `popovertargetaction="show"` so a click always opens (never toggles a card that interest already opened), plus `interestfor` so hover, focus, and long-press also open it where supported.
	 * The card is a `popover` (auto) with `role="dialog"` and an accessible name, and an in-card Close button (`popovertargetaction="hide"`) gives it a real close affordance beyond Esc and light dismiss.
	 */
	render() {
		if ( ! this.profile ) {
			return nothing;
		}

		const cardId = `${ this.uid }-card`;
		const titleId = `${ this.uid }-title`;
		const delayStart = this.clampDelay( this.interestDelayStart, 350 );
		const delayEnd = this.clampDelay( this.interestDelayEnd, 300 );
		const styles = `--hc-anchor:--${ this.uid }; --hc-area:${ this.safePlacement }; --hc-delay-start:${ delayStart }ms; --hc-delay-end:${ delayEnd }ms;`;

		return html`
			<span style=${ styles }>
				<button
					type="button"
					class="trigger"
					popovertarget=${ cardId }
					popovertargetaction="show"
					interestfor=${ cardId }
				>@${ this.profile.handle }</button>

				<div id=${ cardId } class="card" popover role="dialog" aria-labelledby=${ titleId }>
					<div class="card-head">
						<div>
							<h2 id=${ titleId } class="name">${ this.profile.name }</h2>
							<p class="handle">@${ this.profile.handle }</p>
						</div>
						<button
							type="button"
							class="close"
							popovertarget=${ cardId }
							popovertargetaction="hide"
							aria-label="Close ${ this.profile.name } preview"
						>&times;</button>
					</div>
					<p class="bio">${ this.profile.bio }</p>
					<p class="stat"><strong>${ this.formatFollowers( this.profile.followers ) }</strong> followers</p>
					<button type="button" class="follow" aria-pressed=${ this.profile.following } @click=${ this.toggleFollow }>
						${ this.profile.following ? 'Following' : 'Follow' }
					</button>
				</div>
			</span>
		`;
	}

	/**
	 * Toggles the follow state and reports it through the typed `hovercard-follow` event.
	 *
	 * The card stays open after the click, because following someone is not a reason to dismiss the preview, so the close paths (the Close button, Esc, and light dismiss) stay the only way out.
	 * The event is `composed` so it crosses the shadow boundary, and `bubbles` so a listener on the host receives it.
	 * It returns early when `profile` is absent, since the Follow button only renders with a profile, so this guards the optional record and narrows it for the reads below.
	 */
	private toggleFollow = (): void => {
		if ( ! this.profile ) {
			return;
		}

		const following = ! this.profile.following;
		this.profile = { ...this.profile, following };

		this.dispatchEvent(
			new CustomEvent<HovercardFollowDetail>( 'hovercard-follow', {
				detail: { handle: this.profile.handle, following },
				bubbles: true,
				composed: true,
			} ),
		);
	};

	/**
	 * Formats a follower count into a short label, so `4200` reads as `4.2k`.
	 *
	 * @param count - The raw follower count.
	 * @returns The compact label.
	 */
	private formatFollowers( count: number ): string {
		if ( count < 1000 ) {
			return String( count );
		}

		return `${ ( count / 1000 ).toFixed( 1 ).replace( /\.0$/, '' ) }k`;
	}
}

declare global {
	interface HTMLElementTagNameMap {
		'user-hovercard': UserHovercard;
	}
}
The panel shows the component source. Styles live in the component's Lit static styles, scoped to its shadow root.

The division of labor is the real takeaway. What stayed your job was the judgment, and that covers a button handle, the card classified as a dialog by its contents, an accessible name, the explicit click fallback, and the call to verify the screen reader story rather than assume it. Knowing which of those the platform owns, and checking current support instead of guessing, is most of what makes this quick instead of fragile.

A few ways this goes wrong

  • Faking a link to get hover behavior. If the handle is genuinely a link to a profile page, keep it an <a href> and let the fallback be navigation. A button dressed up as a link muddles the click, and a polyfill can’t long-press a link without interfering with the context menu, while the native behavior is still being decided, so if the handle is a real link make navigation the fallback.
  • Leaving the card as a tooltip. A hover bubble with a focusable Follow button is a non-modal dialog; left at the platform’s minimum role=tooltip it’s misclassified, since a tooltip has to be text-only. Set role="dialog" and give it a name.
  • Letting the trigger paths fight. With the default popovertargetaction="toggle", a click after an interest-open toggles the card shut. Use show, and add an explicit in-card Close.
  • Shipping interestfor as the only trigger. It’s Chromium-only, and link invokers have a more delicate touch story, so an interest-only hovercard hides its content from much of the web. The click keeps it reachable.
  • Hiding a required action behind hover. If finishing a task depends on the card, hover is the wrong gate. A preview can sit behind hover; a checkout step can’t.

What separates a senior from a junior here

  • They classify the UI by its contents, not the word in its name. The moment a hover-shown card holds a focusable control it’s a dialog, whichever attribute opened it. That one reclassification is the difference between an accessible card and a broken one.
  • They name which layer is production-safe before writing it. A junior treats “the browser can do this now” as one fact; a senior splits it in two. The top layer, anchoring, and flipping are Baseline and safe, while the interest trigger is a Chromium-only enhancement with a fallback underneath. The build order follows that split.
  • They match the fallback to the trigger’s real job. The click opening isn’t a nicety for old browsers. For this button-triggered card it’s what keeps the card reachable on Firefox and Safari, where the interest trigger does nothing, so it stays in the markup at every step. A real profile link is a different contract, and there the fallback is navigation to the page, not a click.
  • They verify the accessibility claim instead of asserting it. The interest-invoker focus order specifies that the open card is the next Tab stop, so they check in DevTools that Tab from the handle actually moves into the card where interest invokers run. Screen-reader announcement leans on aria-details, historically uneven, so they open a screen reader before promising it works.

When NOT to use a hovercard

A hovercard is a preview, an enhancement over content that’s already reachable some other way. That’s the whole case for it, and also the boundary. If the information or the action lives only inside the card, a hovercard is the wrong pattern, because hover isn’t a reliable way to reach anything. It doesn’t exist on touch, it’s awkward with a keyboard, and it asks for motor precision some users don’t have. For anything essential, a mobile-first flow, or an action that mustn’t hide, put it on the page where a tap reaches it.

For higher-stakes actions, a more conservative variant is to split the model. Let the hover preview be purely informational, a real popover="hint" with no buttons, and make the actionable card a separate popover (auto) that opens only on click. The preview can be a genuine tooltip then, and the action sits behind a deliberate click instead of an accidental hover. It’s more markup, and the right call when the action is something like unfollow or block, where an accidental trigger costs the user something.

Browser supporthint70% of users
Chrome151
Edge151
Firefox153
SafariPreview
Safari iOSNo
Chrome Android151

Browser data updated October 15, 2026

The split model uses popover="hint" where it’s supported, in Chrome, Edge, and Firefox. Don’t assume hint degrades to auto in Safari, though. The popover attribute’s invalid-value default is manual, not auto, so a Safari that doesn’t know hint gives you a popover with no normal light dismiss rather than a regular one. Feature-detect hint before you rely on it, or give the preview a normal popover fallback. The actionable card stays popover (auto), which is Baseline, so the action is never the part that breaks.

Build your own

You’ve played with the finished component above. Now build one yourself, and test it the way it’ll actually get used. Go keyboard only with your hands off the mouse, then a screen reader on, then on a phone where there’s no hover at all. That last fifteen minutes is where you find out whether you built a hovercard or a trap.

Want more of these in your Google results?