20 CSS Popovers09 / 20

Pure CSSMIT licensed

Match Anchor Width for Dropdowns

A floating panel that is exactly as wide as the control that opened it reads as part of that control; one that is a little narrower or wider reads as a bug. anchor-size(width) makes the panel size itself from its anchor with no measurement, and min-width: anchor-size(width) gives the forgiving variant that can still grow for long option labels.

Published

Live Demo
Try it

The code

<div class="pop-09">
  <div class="pop-09__stage">
    <form class="pop-09__form" aria-label="Deployment target">
      <p class="pop-09__legend">Deploy target</p>
      <label class="pop-09__label" for="pop-09-env">Environment</label>
      <button class="pop-09__field" type="button" id="pop-09-env" popovertarget="pop-09-list">
        <span class="pop-09__value">Production · eu-west-1</span>
        <svg class="pop-09__chev" viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M4 6.5 8 10.5 12 6.5"/></svg>
      </button>
      <p class="pop-09__help">The panel below takes its width from this button, live.</p>
      <div class="pop-09__meta">
        <span class="pop-09__pill">anchor-size(width)</span>
        <span class="pop-09__pill pop-09__pill--alt">min-inline-size variant in the CSS</span>
      </div>
    </form>
  </div>

  <div class="pop-09__list" popover="auto" id="pop-09-list" role="group" aria-label="Environments">
    <p class="pop-09__opt pop-09__opt--on">Production · eu-west-1</p>
    <p class="pop-09__opt">Staging · eu-west-1</p>
    <p class="pop-09__opt">Preview · ephemeral per branch</p>
    <p class="pop-09__opt">Local · docker compose</p>
  </div>
</div>
.pop-09 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --bg: #f4f7fb;
  --surface: #ffffff;
  --ink: #101a2b;
  --muted: #5b6b85;
  --rule: #ccd7e6;
  --brand: #1e5aa8;
  --font-ui: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-ui);
  padding: 40px 18px;
}

.pop-09__stage {
  display: grid;
  place-items: center;
  max-inline-size: 100%;
  min-height: calc(100vh - 80px);
}

.pop-09__form {
  inline-size: 100%;
  max-inline-size: 25rem;
  min-inline-size: 0;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 6px;
  padding: 20px 18px 18px;
}

.pop-09__legend {
  margin: 0 0 16px;
  font: 700 10px/1 var(--font-ui);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--brand);
}

.pop-09__label {
  display: block;
  margin-bottom: 6px;
  font-size: 12px;
  font-weight: 600;
  color: var(--muted);
}

.pop-09__field {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  inline-size: 100%;
  min-height: 44px;
  padding: 0 12px;
  font: 500 13.5px/1 var(--font-ui);
  text-align: start;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 5px;
  cursor: pointer;
  anchor-name: --pop-09-anchor;
}

.pop-09__field:hover {
  border-color: var(--brand);
}

.pop-09__field:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 2px;
}

.pop-09__value {
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pop-09__chev {
  flex: none;
  color: var(--muted);
}

.pop-09__help {
  margin: 8px 0 14px;
  font-size: 12px;
  color: var(--muted);
}

.pop-09__meta {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.pop-09__pill {
  padding: 5px 9px;
  font: 600 10.5px/1.3 ui-monospace, Menlo, monospace;
  color: var(--brand);
  background: color-mix(in oklab, var(--brand) 10%, var(--surface));
  border-radius: 3px;
}

.pop-09__pill--alt {
  color: var(--muted);
  background: var(--bg);
}

.pop-09__list {
  position: fixed;
  margin: 0;
  inline-size: min(23rem, calc(100vw - 32px));
  max-inline-size: calc(100vw - 32px);
  padding: 5px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 5px;
  box-shadow: 0 16px 32px -20px rgba(16, 26, 43, 0.55);
}

@supports (anchor-name: --a) {
  .pop-09__list {
    position-anchor: --pop-09-anchor;
    position-area: block-end span-inline-end;
    inset: auto;
    inline-size: anchor-size(width);
    max-inline-size: calc(100vw - 32px);
    margin-block-start: 6px;
  }
}

.pop-09__opt {
  margin: 0;
  padding: 10px 10px;
  font-size: 13px;
  line-height: 1.3;
  border-radius: 3px;
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pop-09__opt + .pop-09__opt {
  margin-block-start: 1px;
}

.pop-09__opt--on {
  font-weight: 650;
  color: var(--brand);
  background: color-mix(in oklab, var(--brand) 9%, var(--surface));
}

@media (prefers-color-scheme: dark) {
  .pop-09 {
    --bg: #0d131d;
    --surface: #161e2b;
    --ink: #e9eff8;
    --muted: #8f9fb8;
    --rule: #2a3547;
    --brand: #74a8ee;
  }
}

[data-theme="dark"] .pop-09 {
  --bg: #0d131d;
  --surface: #161e2b;
  --ink: #e9eff8;
  --muted: #8f9fb8;
  --rule: #2a3547;
  --brand: #74a8ee;
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Popover from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Match Anchor Width for Dropdowns
Source: https://codefronts.com/snippets/css-popovers/match-anchor-width-for-dropdowns/

A floating panel that is exactly as wide as the control that opened it reads as part of that control; one that is a little narrower or wider reads as a bug. anchor-size(width) makes the panel size itself from its anchor with no measurement, and min-width: anchor-size(width) gives the forgiving variant that can still grow for long option labels.
## HTML
```html
<div class="pop-09">
  <div class="pop-09__stage">
    <form class="pop-09__form" aria-label="Deployment target">
      <p class="pop-09__legend">Deploy target</p>
      <label class="pop-09__label" for="pop-09-env">Environment</label>
      <button class="pop-09__field" type="button" id="pop-09-env" popovertarget="pop-09-list">
        <span class="pop-09__value">Production · eu-west-1</span>
        <svg class="pop-09__chev" viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M4 6.5 8 10.5 12 6.5"/></svg>
      </button>
      <p class="pop-09__help">The panel below takes its width from this button, live.</p>
      <div class="pop-09__meta">
        <span class="pop-09__pill">anchor-size(width)</span>
        <span class="pop-09__pill pop-09__pill--alt">min-inline-size variant in the CSS</span>
      </div>
    </form>
  </div>

  <div class="pop-09__list" popover="auto" id="pop-09-list" role="group" aria-label="Environments">
    <p class="pop-09__opt pop-09__opt--on">Production · eu-west-1</p>
    <p class="pop-09__opt">Staging · eu-west-1</p>
    <p class="pop-09__opt">Preview · ephemeral per branch</p>
    <p class="pop-09__opt">Local · docker compose</p>
  </div>
</div>
```
## CSS
```css
.pop-09 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --bg: #f4f7fb;
  --surface: #ffffff;
  --ink: #101a2b;
  --muted: #5b6b85;
  --rule: #ccd7e6;
  --brand: #1e5aa8;
  --font-ui: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-ui);
  padding: 40px 18px;
}

.pop-09__stage {
  display: grid;
  place-items: center;
  max-inline-size: 100%;
  min-height: calc(100vh - 80px);
}

.pop-09__form {
  inline-size: 100%;
  max-inline-size: 25rem;
  min-inline-size: 0;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 6px;
  padding: 20px 18px 18px;
}

.pop-09__legend {
  margin: 0 0 16px;
  font: 700 10px/1 var(--font-ui);
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--brand);
}

.pop-09__label {
  display: block;
  margin-bottom: 6px;
  font-size: 12px;
  font-weight: 600;
  color: var(--muted);
}

.pop-09__field {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  inline-size: 100%;
  min-height: 44px;
  padding: 0 12px;
  font: 500 13.5px/1 var(--font-ui);
  text-align: start;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 5px;
  cursor: pointer;
  anchor-name: --pop-09-anchor;
}

.pop-09__field:hover {
  border-color: var(--brand);
}

.pop-09__field:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 2px;
}

.pop-09__value {
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pop-09__chev {
  flex: none;
  color: var(--muted);
}

.pop-09__help {
  margin: 8px 0 14px;
  font-size: 12px;
  color: var(--muted);
}

.pop-09__meta {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.pop-09__pill {
  padding: 5px 9px;
  font: 600 10.5px/1.3 ui-monospace, Menlo, monospace;
  color: var(--brand);
  background: color-mix(in oklab, var(--brand) 10%, var(--surface));
  border-radius: 3px;
}

.pop-09__pill--alt {
  color: var(--muted);
  background: var(--bg);
}

.pop-09__list {
  position: fixed;
  margin: 0;
  inline-size: min(23rem, calc(100vw - 32px));
  max-inline-size: calc(100vw - 32px);
  padding: 5px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 5px;
  box-shadow: 0 16px 32px -20px rgba(16, 26, 43, 0.55);
}

@supports (anchor-name: --a) {
  .pop-09__list {
    position-anchor: --pop-09-anchor;
    position-area: block-end span-inline-end;
    inset: auto;
    inline-size: anchor-size(width);
    max-inline-size: calc(100vw - 32px);
    margin-block-start: 6px;
  }
}

.pop-09__opt {
  margin: 0;
  padding: 10px 10px;
  font-size: 13px;
  line-height: 1.3;
  border-radius: 3px;
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pop-09__opt + .pop-09__opt {
  margin-block-start: 1px;
}

.pop-09__opt--on {
  font-weight: 650;
  color: var(--brand);
  background: color-mix(in oklab, var(--brand) 9%, var(--surface));
}

@media (prefers-color-scheme: dark) {
  .pop-09 {
    --bg: #0d131d;
    --surface: #161e2b;
    --ink: #e9eff8;
    --muted: #8f9fb8;
    --rule: #2a3547;
    --brand: #74a8ee;
  }
}

[data-theme="dark"] .pop-09 {
  --bg: #0d131d;
  --surface: #161e2b;
  --ink: #e9eff8;
  --muted: #8f9fb8;
  --rule: #2a3547;
  --brand: #74a8ee;
}
```

How this works

anchor-size() turns the anchor's box into a length. Write inline-size: anchor-size(width) on the panel and it is exactly as wide as its anchor, tracked live as the anchor resizes. This is the detail that separates a dropdown from a floating box: the seam between trigger and panel disappears, and no JavaScript ever reads offsetWidth.

Exact width is the strict version; a minimum is the humane one. min-inline-size: anchor-size(width) lets the panel grow past its trigger when an option label is long, while never being narrower — the behaviour most native comboboxes actually have. Pair it with a max-inline-size against the viewport so a long label cannot push the panel off screen.

It composes with calc, which is where it gets useful. inline-size: calc(anchor-size(width) + 2rem) gives a panel that overhangs its trigger evenly; anchor-size(height) can drive a matching row height for a side panel. Any length position accepts it, so paddings and offsets can be expressed relative to the anchor rather than to guesses.

Two traps. First, the wrong anchor: anchor-size() resolves against the panel's position-anchor, so if the anchor is the icon inside the button rather than the button itself, you get the icon's width. Put anchor-name on the element whose width you actually mean. Second, no fallback: without anchor support the declaration is invalid and dropped, so declare a sensible fixed width first and let the anchored version override it.

Make it yours

  • Switch inline-size to min-inline-size for the grow-only variant.
  • Add calc(anchor-size(width) + 1.5rem) for a panel that overhangs its trigger.
  • Use anchor-size(height) to give panel rows the same height as the trigger.
  • Change position-area to block-start span-inline-end to open upward.
  • Retint the field and panel by editing --brand on the root.
  • Raise the panel's max-inline-size if your option labels are long.

Gotchas — read before shipping

  • anchor-size() reads the element named by position-anchor, so naming an inner icon instead of the button silently gives you the icon's width.
  • An exact inline-size: anchor-size(width) truncates long labels, which is why the min-width variant is usually the better default.
  • Where anchor positioning is unsupported the whole declaration is invalid and dropped, so a fallback width has to be declared before it.
  • A trigger that changes width on hover or focus makes the panel resize mid-interaction; keep the trigger's box stable.

Browser support

ChromeSafariFirefoxEdge
125+26+131+125+

anchor-size() is part of anchor positioning and shares its floor: Chrome 125, Safari 26, Firefox 131. Popover, which supplies the top layer and dismissal here, is Chrome 114, Safari 17, Firefox 125 — so a browser can perfectly well open this panel and be unable to size it, which is exactly why the fixed-width fallback comes first in the stylesheet.

Techniques used in this demo

Search CodeFronts

Loading…