Pure CSS Components

Felipe Fialho

2014

HTML and CSS: hidden form inputs, :target, sibling selectors

In 2014, a modal, a carousel or a set of tabs usually meant a jQuery plugin. This project built six of them with no JavaScript at all, storing their state in checkboxes, radio buttons and the URL hash. Front-end developers across Brazil shipped them to production.

The platform has since caught up. Each component below sits next to the way HTML and CSS build it natively today. Both versions work, so try them.

Collapse

  • Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.

    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.

  • Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.

    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.

  • Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.

    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.

With Accordion

  • Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.

    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.

  • Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.

    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.

  • Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.

    Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.

Original

2014

Hidden checkbox/radio + general sibling (~) selector

The "Collapse" component can be used in two ways: one that opens each element that is clicked OR like an accordion, where only the last clicked element remains open.

View the 2014 code
HTML
<!-- Collapse -->
<div class="collapse">
  <ul class="collapse-list">
    <li>
      <label class="collapse-btn" for="then-collapse-1">Lorem ipsum dolor sit amet</label>
      <input class="collapse-open" type="checkbox" id="then-collapse-1" aria-hidden="true" hidden>
      <div class="collapse-panel">
        <div class="collapse-inner">
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.</p>
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.</p>
        </div>
      </div>
    </li>
    <li>
      <label class="collapse-btn" for="then-collapse-2">Lorem ipsum dolor sit amet</label>
      <input class="collapse-open" type="checkbox" id="then-collapse-2" aria-hidden="true" hidden>
      <div class="collapse-panel">
        <div class="collapse-inner">
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.</p>
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.</p>
        </div>
      </div>
    </li>
    <li>
      <label class="collapse-btn" for="then-collapse-3">Lorem ipsum dolor sit amet</label>
      <input class="collapse-open" type="checkbox" id="then-collapse-3" aria-hidden="true" hidden>
      <div class="collapse-panel">
        <div class="collapse-inner">
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.</p>
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.</p>
        </div>
      </div>
    </li>
  </ul>
</div>
<!-- /Collapse -->

<h2>With Accordion</h2>

<!-- Collapse -->
<div class="collapse">
  <ul class="collapse-list">
    <li>
      <label class="collapse-btn" for="then-accordion-1">Lorem ipsum dolor sit amet</label>
      <input class="collapse-open" type="radio" id="then-accordion-1" name="then-accordion" aria-hidden="true" hidden checked>
      <div class="collapse-panel">
        <div class="collapse-inner">
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.</p>
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.</p>
        </div>
      </div>
    </li>
    <li>
      <label class="collapse-btn" for="then-accordion-2">Lorem ipsum dolor sit amet</label>
      <input class="collapse-open" type="radio" id="then-accordion-2" name="then-accordion" aria-hidden="true" hidden>
      <div class="collapse-panel">
        <div class="collapse-inner">
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.</p>
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.</p>
        </div>
      </div>
    </li>
    <li>
      <label class="collapse-btn" for="then-accordion-3">Lorem ipsum dolor sit amet</label>
      <input class="collapse-open" type="radio" id="then-accordion-3" name="then-accordion" aria-hidden="true" hidden>
      <div class="collapse-panel">
        <div class="collapse-inner">
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.</p>
          <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Optio, non.</p>
        </div>
      </div>
    </li>
  </ul>
</div>
<!-- /Collapse -->
CSS
/* Collapse - Pure CSS (no JavaScript) */

.collapse-open {
  display: none;
}

.collapse-panel {
  visibility: hidden;
  max-height: 0;
  opacity: 0;
  transition: max-height 0.1s, visibility 0.3s, opacity 0.3s;
}

.collapse-open:checked ~ .collapse-panel {
  max-height: 100%;
  opacity: 100;
  visibility: visible;
}

/* Collapse List */

.collapse-list {
  margin-bottom: 0;
  padding-left: 0;
  list-style: none;
}

.collapse-list li {
  margin-bottom: 10px;
}

.collapse-list .collapse-btn {
  border: #eaeaea solid 1px;
  background: #e8e8e8;
  border-radius: 2px;
  cursor: pointer;
  display: block;
  padding: 5px 10px;
}

.collapse-list .collapse-btn:hover {
  background: #eaeaea;
}

.collapse-list .collapse-inner {
  padding: 10px;
}
Why keep the 2014 version?

It shows how far CSS hacks had to go before the platform caught up. Each original demo still runs as it shipped.

Can I still use the originals?

You can, but the native versions need less code and work better with a keyboard and screen reader. Treat the originals as history.

What replaced the checkbox trick?

The <details> element. It toggles, announces its state and opens itself for find-in-page, with no hidden input.

Exclusive accordion

Install

Clone the repository and run pnpm install. Node 22.12 or newer is required.

Run locally

Start the dev server with pnpm dev and open the local address it prints.

Build

pnpm build type-checks the project and writes a static site to dist.

Native

2026

<details> + <summary>, exclusive with name

<details> gives you what the 2014 checkbox faked: a real button that works with Tab, Enter and Space, announces its state, and opens when find-in-page matches hidden text. A shared name makes the group exclusive.

The motion targets ::details-content with interpolate-size: allow-keywords, so height eases to and from auto with no max-height guess. It only runs without reduced motion; other browsers snap open instead.

View the native code
HTML
<div class="collapse">
  <details>
    <summary>
      Why keep the 2014 version?
      <svg class="icon chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
    </summary>
    <div class="panel">
      <p>It shows how far CSS hacks had to go before the platform caught up. Each original demo still runs as it shipped.</p>
    </div>
  </details>
  <details>
    <summary>
      Can I still use the originals?
      <svg class="icon chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
    </summary>
    <div class="panel">
      <p>You can, but the native versions need less code and work better with a keyboard and screen reader. Treat the originals as history.</p>
    </div>
  </details>
  <details>
    <summary>
      What replaced the checkbox trick?
      <svg class="icon chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
    </summary>
    <div class="panel">
      <p>The <code>&lt;details&gt;</code> element. It toggles, announces its state and opens itself for find-in-page, with no hidden input.</p>
    </div>
  </details>
</div>

<h3 class="variant">Exclusive accordion</h3>

<!-- Same name = exclusive group: opening one closes the others -->
<div class="collapse">
  <details name="now-accordion" open>
    <summary>
      Install
      <svg class="icon chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
    </summary>
    <div class="panel">
      <p>Clone the repository and run <code>pnpm install</code>. Node 22.12 or newer is required.</p>
    </div>
  </details>
  <details name="now-accordion">
    <summary>
      Run locally
      <svg class="icon chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
    </summary>
    <div class="panel">
      <p>Start the dev server with <code>pnpm dev</code> and open the local address it prints.</p>
    </div>
  </details>
  <details name="now-accordion">
    <summary>
      Build
      <svg class="icon chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
    </summary>
    <div class="panel">
      <p><code>pnpm build</code> type-checks the project and writes a static site to <code>dist</code>.</p>
    </div>
  </details>
</div>
CSS
.collapse {
  overflow: clip;
  border: 1px solid var(--border);
  border-radius: var(--radius-surface);
  background: var(--surface-raised);
  box-shadow: var(--shadow-sm);
}

.collapse details + details {
  border-block-start: 1px solid var(--border);
}

summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  min-block-size: 48px;
  padding: 10px 16px;
  font-weight: 550;
  cursor: pointer;
  list-style: none;
}

/* Safari still renders its own marker */
summary::-webkit-details-marker {
  display: none;
}

summary:hover {
  background: var(--hover);
}

summary:focus-visible {
  outline-offset: -2px;
}

/* Decorative: <summary> already exposes expanded/collapsed */
.chevron {
  color: var(--ink-soft);
}

[open] > summary .chevron {
  rotate: 180deg;
}

.panel {
  padding: 0 16px 16px;
  color: var(--ink-soft);
}

.panel > p {
  margin: 0;
  max-inline-size: 60ch;
}

/* Animate the native content slot to and from height: auto */
@media (prefers-reduced-motion: no-preference) {
  summary {
    transition: background-color var(--duration) var(--ease);
  }

  .chevron {
    transition: rotate 220ms var(--ease);
  }

  @supports (interpolate-size: allow-keywords) and selector(::details-content) {
    :scope {
      interpolate-size: allow-keywords;
    }

    details::details-content {
      block-size: 0;
      overflow-y: clip;
      transition:
        block-size 220ms var(--ease),
        content-visibility 220ms allow-discrete;
    }

    details[open]::details-content {
      block-size: auto;
    }
  }
}

Tab

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque, commodi. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque, commodi.
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque, commodi.
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque, commodi. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque, commodi. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque, commodi.

Original

2014

Hidden radio inputs + label navigation + :checked sibling selector

The "tab" component presents an interesting behavior and could be used without any issues. You do not need any special adaptation in the CSS.

View the 2014 code
HTML
<!-- Tab -->
<div class="tab">

  <!-- Tab panel -->
  <div class="tab-panel">
    <input class="tab-open" id="then-tab-1" name="then-tab-wrap-1" type="radio" checked>
    <label class="tab-nav" for="then-tab-1">Tab 1</label>
    <div class="tab-inner">
      Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque, commodi. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque, commodi.
    </div>
  </div>

  <div class="tab-panel">
    <input class="tab-open" id="then-tab-2" name="then-tab-wrap-1" type="radio">
    <label class="tab-nav" for="then-tab-2">Tab 2</label>
    <div class="tab-inner">
      Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque, commodi.
    </div>
  </div>

  <div class="tab-panel">
    <input class="tab-open" id="then-tab-3" name="then-tab-wrap-1" type="radio">
    <label class="tab-nav" for="then-tab-3">Tab 3</label>
    <div class="tab-inner">
      Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque, commodi. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque, commodi. Lorem ipsum dolor sit amet, consectetur adipisicing elit. Atque, commodi.
    </div>
  </div>

</div>
CSS
/* Tab - Pure CSS (no JavaScript) */

.tab {
  position: relative;
}

.tab-panel {
  display: inline-block;
  min-height: 150px;
}

.tab-panel .tab-open {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}

/* Tab - Nav */

.tab-nav {
  border: #aaa solid 1px;
  cursor: pointer;
  font-size: 14px;
  display: inline-block;
  padding: 10px 20px;
  margin-right: 20px;
}

.tab-open,
.tab-nav:hover,
.tab-open:checked ~ .tab-nav {
  background-color: #eaeaea;
}

/* Tab - Container */

.tab-inner {
  opacity: 0;
  margin-top: 20px;
  position: absolute;
  left: 0;
  transition: opacity 0.1s;
  visibility: hidden;
}

.tab-open:focus:checked ~ .tab-nav {
  outline: thin dotted;
}

.tab-open:checked ~ .tab-inner {
  opacity: 100;
  visibility: visible;
  transition: opacity 0.6s ease;
}
Profile

How your name appears on the archive.

Display name
Felipe Fialho
Handle
@felipefialho
Website
felipefialho.com
Notifications

Choose where updates reach you.

Pull request reviews
Email and web
Mentions
Web only
Weekly digest
Off
Privacy

Control who can see your activity.

Profile visibility
Public
Activity feed
Followers only

Native

2026

Exclusive <details name> laid out as tabs

An exclusive <details> group gives you focusable tabs with a real expanded state and no hidden radios. With display: contents, every <summary> joins one grid row and its ::details-content fills the row below.

Honest caveat: HTML still has no tabs element. Screen readers hear disclosure buttons, arrow keys do not move between tabs, and full ARIA tabs still need a little script. Older browsers show a plain accordion.

View the native code
HTML
<!-- An exclusive <details> group, laid out as tabs where ::details-content is supported -->
<div class="tabs">
  <details name="now-tabs" open>
    <summary>Profile</summary>
    <div class="panel">
      <p class="lead">How your name appears on the archive.</p>
      <dl class="rows">
        <div><dt>Display name</dt><dd>Felipe Fialho</dd></div>
        <div><dt>Handle</dt><dd>@felipefialho</dd></div>
        <div><dt>Website</dt><dd>felipefialho.com</dd></div>
      </dl>
    </div>
  </details>
  <details name="now-tabs">
    <summary>Notifications</summary>
    <div class="panel">
      <p class="lead">Choose where updates reach you.</p>
      <dl class="rows">
        <div><dt>Pull request reviews</dt><dd>Email and web</dd></div>
        <div><dt>Mentions</dt><dd>Web only</dd></div>
        <div><dt>Weekly digest</dt><dd>Off</dd></div>
      </dl>
    </div>
  </details>
  <details name="now-tabs">
    <summary>Privacy</summary>
    <div class="panel">
      <p class="lead">Control who can see your activity.</p>
      <dl class="rows">
        <div><dt>Profile visibility</dt><dd>Public</dd></div>
        <div><dt>Activity feed</dt><dd>Followers only</dd></div>
      </dl>
    </div>
  </details>
</div>
CSS
/* Baseline look: a plain exclusive accordion, fully usable anywhere */
.tabs {
  overflow: clip;
  border: 1px solid var(--border);
  border-radius: var(--radius-surface);
  background: var(--surface-raised);
  box-shadow: var(--shadow-sm);
}

.tabs details + details {
  border-block-start: 1px solid var(--border);
}

summary {
  padding: 12px 16px;
  font-weight: 550;
  cursor: pointer;
  list-style: none;
}

summary::-webkit-details-marker {
  display: none;
}

summary:focus-visible {
  outline-offset: -2px;
}

.panel {
  padding: 4px 16px 16px;
}

.lead {
  margin: 0 0 8px;
  color: var(--ink-soft);
}

.rows {
  margin: 0;
}

.rows > div {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 2px 1rem;
  padding-block: 10px;
  border-block-start: 1px solid var(--border);
}

.rows dt {
  color: var(--ink-soft);
}

.rows dd {
  margin: 0;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}

/* Tabs layout: dissolve each <details> so its summary and content slot join one grid */
@supports selector(::details-content) {
  .tabs {
    display: grid;
    grid-template-columns: repeat(3, max-content) 1fr;
    padding-inline: 8px;
  }

  .tabs details {
    display: contents;
  }

  summary {
    grid-row: 1;
    padding: 14px 10px 12px;
    border-block-end: 1px solid var(--border);
    color: var(--ink-soft);
  }

  summary:hover {
    color: var(--ink);
  }

  /* Draw the tab strip line across the empty last column too */
  .tabs::after {
    content: "";
    grid-area: 1 / 4;
    border-block-end: 1px solid var(--border);
  }

  [open] > summary {
    color: var(--ink);
    box-shadow: inset 0 -2px var(--accent);

    /* A tab should not close when clicked again */
    pointer-events: none;
  }

  details::details-content {
    grid-area: 2 / 1 / 3 / -1;
  }

  details:not([open])::details-content {
    display: none;
  }

  .panel {
    padding: 16px 8px 4px;
  }
}

@media (prefers-reduced-motion: no-preference) {
  summary {
    transition: color var(--duration) var(--ease);
  }
}

Tooltip

Original

2014

CSS-only :hover/:focus + ::before/::after pseudo-elements reading aria-label

By Deivid Marques

Tooltip displayed with hover or focus only css. Wai-aria was used which leaves the element accessible for screen readers.

View the 2014 code
HTML
<!-- Tooltip -->
<a href="#tooltip" role="tooltip" class="tooltip-top btn" aria-label="content displayed over ">Top</a>
<a href="#tooltip" role="tooltip" class="tooltip-right btn" aria-label="content displayed to the right">Right</a>
<a href="#tooltip" role="tooltip" class="tooltip-bottom btn" aria-label="content displayed below ">Bottom</a>
<a href="#tooltip" role="tooltip" class="tooltip-left btn" aria-label="content displayed to the left ">Left</a>
CSS
/* Tooltip - Pure CSS (no JavaScript) - By Deivid Marques */

[class*='tooltip'] {
  position: relative;
  text-decoration: none;
}

[class*='tooltip']:after,
[class*='tooltip']:before {
  position: absolute;
  z-index: 100;
  opacity: 0;
}

[class*='tooltip']:after {
  box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.3);
  content: attr(aria-label);
  height: 25px;
  line-height: 25px;
  padding: 0 10px;
  font-size: 12px;
  text-align: center;
  color: #fff;
  background: #222;
  border-radius: 4px;
  text-shadow: 0 0 5px #000;
  white-space: nowrap;
}

[class*='tooltip']:before {
  content: '';
  width: 0;
  height: 0;
  border-width: 6px;
  border-style: solid;
}

[class*='tooltip']:hover:after,
[class*='tooltip']:focus:after,
[class*='tooltip']:hover:before,
[class*='tooltip']:focus:before {
  opacity: 1;
}

/* Tooltip - Top */

.tooltip-top:after,
.tooltip-top:before {
  transition: bottom 0.25s ease-in-out;
  bottom: 90%;
  left: -9999px;
  margin-bottom: 12px;
}

.tooltip-top:before {
  border-color: #222 transparent transparent transparent;
  margin-bottom: 0;
}

.tooltip-top:hover:after,
.tooltip-top:focus:after,
.tooltip-top:hover:before,
.tooltip-top:focus:before {
  bottom: 85%;
  left: 0;
}

.tooltip-top:hover:before,
.tooltip-top:focus:before {
  left: 15px;
}

/* Tooltip - Bottom */

.tooltip-bottom:after,
.tooltip-bottom:before {
  transition: top 0.25s ease-in-out;
  top: 90%;
  left: -9999px;
  margin-top: 12px;
}

.tooltip-bottom:before {
  border-color: transparent transparent #222 transparent;
  margin-top: 0;
}

.tooltip-bottom:hover:after,
.tooltip-bottom:focus:after,
.tooltip-bottom:hover:before,
.tooltip-bottom:focus:before {
  top: 85%;
  left: 0;
}

.tooltip-bottom:hover:before,
.tooltip-bottom:focus:before {
  left: 15px;
}

/* Tooltip - Right */

.tooltip-right:after,
.tooltip-right:before {
  transition: left 0.25s ease-in-out;
  top: -9999px;
  left: 96%;
  margin-left: 12px;
}

.tooltip-right:before {
  border-color: transparent #222 transparent transparent;
  margin-left: 0;
}

.tooltip-right:hover:after,
.tooltip-right:focus:after,
.tooltip-right:hover:before,
.tooltip-right:focus:before {
  left: 95%;
  top: 0;
}

.tooltip-right:hover:before,
.tooltip-right:focus:before {
  top: 7px;
}

/* Tooltip - Left */

.tooltip-left:after,
.tooltip-left:before {
  transition: right 0.25s ease-in-out;
  top: -9999px;
  right: 96%;
  margin-right: 12px;
}

.tooltip-left:before {
  border-color: transparent transparent transparent #222;
  margin-right: 0;
}

.tooltip-left:hover:after,
.tooltip-left:focus:after,
.tooltip-left:hover:before,
.tooltip-left:focus:before {
  right: 95%;
  top: 0;
}

.tooltip-left:hover:before,
.tooltip-left:focus:before {
  top: 7px;
}

/* Add opacity animation */

[class*='tooltip']:after,
[class*='tooltip']:before {
  transition: opacity 0.25s ease-in-out;
}
Bold Italic Insert link Code

Native

2026

popover="hint" + interestfor + anchor positioning

interestfor with popover="hint" gives you show and hide delays, hover, focus and long-press triggers, and Escape to dismiss, which a ::after tooltip never had. Each icon button takes its name from its tooltip.

position-area puts each hint on its own side, and position-try-fallbacks flips it near the viewport edge. Without interest invokers, :hover and :focus-visible show the same element, but Escape cannot dismiss it.

View the native code
HTML
<!-- Icon buttons take their name from the tooltip, so it is read once, not twice -->
<div class="toolbar" role="group" aria-label="Text formatting">
  <button class="icon-button tip-left" type="button" interestfor="now-tip-left" aria-labelledby="now-tip-left">
    <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 12h8a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h6a4 4 0 0 1 0 8"/></svg>
  </button>
  <span id="now-tip-left" class="tooltip tooltip-left" popover="hint">Bold</span>

  <button class="icon-button tip-top" type="button" interestfor="now-tip-top" aria-labelledby="now-tip-top">
    <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 4h-9"/><path d="M14 20H5"/><path d="M15 4 9 20"/></svg>
  </button>
  <span id="now-tip-top" class="tooltip tooltip-top" popover="hint">Italic</span>

  <button class="icon-button tip-bottom" type="button" interestfor="now-tip-bottom" aria-labelledby="now-tip-bottom">
    <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
  </button>
  <span id="now-tip-bottom" class="tooltip tooltip-bottom" popover="hint">Insert link</span>

  <button class="icon-button tip-right" type="button" interestfor="now-tip-right" aria-labelledby="now-tip-right">
    <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>
  </button>
  <span id="now-tip-right" class="tooltip tooltip-right" popover="hint">Code</span>
</div>
CSS
:scope {
  display: grid;
  place-items: center;
}

.toolbar {
  display: inline-flex;
  gap: 2px;
  padding: 4px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface-raised);
  box-shadow: var(--shadow-sm);
}

.toolbar .icon-button {
  interest-delay: 200ms 150ms;
}

.tip-top { anchor-name: --now-tip-top; }
.tip-right { anchor-name: --now-tip-right; }
.tip-bottom { anchor-name: --now-tip-bottom; }
.tip-left { anchor-name: --now-tip-left; }

.tooltip {
  --gap: 8px;

  overflow: visible;
  inset: auto;
  margin: 0;
  padding: 5px 8px;
  border: 0;
  border-radius: 6px;
  background: var(--ink);
  box-shadow: var(--shadow-sm);
  color: var(--label);
  font-size: 12.5px;
  font-weight: 550;
  line-height: 1.2;
  white-space: nowrap;
}

/* Arrow: a rotated square that sits half outside the facing edge */
.tooltip::before {
  content: "";
  position: absolute;
  inline-size: 7px;
  block-size: 7px;
  background: inherit;
  rotate: 45deg;
}

.tooltip-top {
  position-anchor: --now-tip-top;
  position-area: top;
  margin-bottom: var(--gap);
  position-try-fallbacks: flip-block;
}

.tooltip-top::before {
  inset: auto auto -3px calc(50% - 3.5px);
}

.tooltip-right {
  position-anchor: --now-tip-right;
  position-area: right;
  margin-left: var(--gap);
  position-try-fallbacks: flip-inline;
}

.tooltip-right::before {
  inset: calc(50% - 3.5px) auto auto -3px;
}

.tooltip-bottom {
  position-anchor: --now-tip-bottom;
  position-area: bottom;
  margin-top: var(--gap);
  position-try-fallbacks: flip-block;
}

.tooltip-bottom::before {
  inset: -3px auto auto calc(50% - 3.5px);
}

.tooltip-left {
  position-anchor: --now-tip-left;
  position-area: left;
  margin-right: var(--gap);
  position-try-fallbacks: flip-inline;
}

.tooltip-left::before {
  inset: calc(50% - 3.5px) -3px auto auto;
}

/* No interest invokers: show the same popover on hover or keyboard focus */
@supports not selector(:interest-source) {
  .icon-button:is(:hover, :focus-visible) + .tooltip {
    display: block;
    position: fixed;
    opacity: 1;
  }
}

@media (prefers-reduced-motion: no-preference) {
  .tooltip {
    transition:
      opacity 150ms var(--ease),
      scale 150ms var(--ease),
      display 150ms allow-discrete,
      overlay 150ms allow-discrete;
  }

  @starting-style {
    .tooltip:popover-open {
      opacity: 0;
      scale: 0.96;
    }
  }

  .tooltip:not(:popover-open) {
    opacity: 0;
    scale: 0.96;
  }
}

Born native

These have no 2014 version. No hidden input could fake them, and until recently each one meant a script. Now they are HTML and CSS. Some features are new, so every exhibit lists where it works today.

Custom select

Native

2026

appearance: base-select + <selectedcontent>

select, ::picker(select) { appearance: base-select } turns a plain <select> into a fully styleable control: real <option> markup with icons and descriptions, a ::checkmark, and a picker positioned by anchor positioning. No script needed.

<selectedcontent> clones the selected option's markup into the closed button, so the status dot updates itself; a CSS rule hides the description in that clone. Chrome and Safari support it; Firefox falls back to a plain, fully usable select.

View the code
HTML
<div class="field">
  <label for="born-select-status">Status</label>

  <!-- appearance: base-select opts this <select> and its picker into full styling; unsupported browsers fall back to a normal, fully usable select -->
  <select id="born-select-status" name="status">
    <!-- <button><selectedcontent> is the select button; the arrow is the browser's own ::picker-icon, not authored markup -->
    <button type="button">
      <!-- selectedcontent clones the selected option's markup, so the dot updates itself; the description is hidden here by CSS, not by the browser -->
      <selectedcontent></selectedcontent>
    </button>

    <option value="draft">
      <span class="dot dot-draft" aria-hidden="true"></span>
      <span class="option-body">
        <span class="option-label">Draft</span>
        <span class="option-desc">Only visible to you</span>
      </span>
    </option>
    <option value="review" selected>
      <span class="dot dot-review" aria-hidden="true"></span>
      <span class="option-body">
        <span class="option-label">In review</span>
        <span class="option-desc">Shared with your team</span>
      </span>
    </option>
    <option value="published">
      <span class="dot dot-published" aria-hidden="true"></span>
      <span class="option-body">
        <span class="option-label">Published</span>
        <span class="option-desc">Live on the site</span>
      </span>
    </option>
  </select>
</div>
CSS
.field {
  display: grid;
  gap: 6px;
  inline-size: fit-content;
  min-inline-size: 15rem;
}

.field label {
  color: var(--ink-soft);
  font-size: 13px;
  font-weight: 600;
}

#born-select-status {
  inline-size: 100%;
  min-block-size: 42px;
  padding-inline: 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-control);
  background: var(--surface-raised);
  box-shadow: var(--shadow-sm);
  color: var(--ink);
  font: inherit;
  text-align: start;
  cursor: pointer;
}

#born-select-status:hover {
  background: color-mix(in srgb, var(--ink) 5%, var(--surface-raised));
}

.option-body {
  display: grid;
  gap: 1px;
}

.option-label {
  font-weight: 550;
}

.option-desc {
  color: var(--ink-soft);
  font-size: 12.5px;
}

.dot {
  flex: none;
  inline-size: 9px;
  block-size: 9px;
  border-radius: 50%;
}

.dot-draft {
  background: light-dark(#9aa4ae, #5a6570);
}

.dot-review {
  background: light-dark(#c9871c, #e0a53c);
}

.dot-published {
  background: light-dark(#2f9e5c, #4bc27f);
}

/* Customizable select: opt the control and its picker into full styling. Without support, this stays a plain, fully usable select */
@supports (appearance: base-select) {
  #born-select-status,
  #born-select-status::picker(select) {
    appearance: base-select;
  }

  #born-select-status {
    display: flex;
    align-items: center;
    gap: 10px;
  }

  #born-select-status::picker-icon {
    color: var(--ink-soft);
  }

  #born-select-status:open::picker-icon {
    rotate: 180deg;
  }

  #born-select-status selectedcontent {
    display: flex;
    flex: 1;
    align-items: center;
    gap: 8px;
  }

  /* The clone inside the closed button keeps only the dot and label */
  #born-select-status selectedcontent .option-desc {
    display: none;
  }

  #born-select-status::picker(select) {
    min-inline-size: 15rem;
    margin-block-start: 6px;
    padding: 6px;
    border: 1px solid var(--border);
    border-radius: 12px;
    background: var(--surface-raised);
    box-shadow: var(--shadow-pop);
  }

  #born-select-status option {
    display: grid;
    grid-template-columns: 1rem auto 1fr;
    place-items: center start;
    column-gap: 10px;
    padding: 8px 10px;
    border-radius: 8px;
    text-align: start;
  }

  #born-select-status option:hover,
  #born-select-status option:focus-visible {
    background: var(--hover);
  }

  #born-select-status option:checked {
    background: var(--hover);
    font-weight: 550;
  }

  /* Reserve a fixed first column for the checkmark so unchecked options
     keep the same space as the checked one, keeping dots/titles aligned */
  #born-select-status option::checkmark {
    grid-column: 1;
    justify-self: start;
    margin-inline-start: 0;
    color: var(--accent);
  }

  #born-select-status option .dot {
    grid-column: 2;
  }

  #born-select-status option .option-body {
    grid-column: 3;
  }
}

@media (prefers-reduced-motion: no-preference) {
  #born-select-status::picker-icon {
    transition: rotate var(--duration) var(--ease);
  }

  #born-select-status::picker(select) {
    transition:
      opacity var(--duration) var(--ease),
      translate var(--duration) var(--ease),
      display var(--duration) allow-discrete,
      overlay var(--duration) allow-discrete;
    opacity: 0;
    translate: 0 -4px;
  }

  #born-select-status:open::picker(select) {
    opacity: 1;
    translate: 0;
  }

  @starting-style {
    #born-select-status:open::picker(select) {
      opacity: 0;
      translate: 0 -4px;
    }
  }
}

Switch & segmented control

Billing

Native

2026

<input type="checkbox" switch> + :has()

The switch attribute keeps a checkbox as a checkbox, form submission and all, while exposing switch semantics to assistive tech instead of the usual role="switch" markup. appearance: none and :checked give it one look across browsers, supported or not.

The billing control is a real radio group; its sliding thumb is one ::before moved with :has(#id:checked), no JavaScript. Only Safari implements switch today; elsewhere it renders as a plain checkbox, which this demo styles identically either way.

View the code
HTML
<div class="stack">
  <!-- A real checkbox with the switch attribute: same behavior as a checkbox, switch semantics for assistive tech -->
  <label class="switch-row" for="born-switch-notify">
    <span class="switch-row-text">
      <span class="switch-row-title">Email notifications</span>
      <span class="switch-row-desc">Get a weekly summary of activity on your projects</span>
    </span>
    <input class="switch" type="checkbox" id="born-switch-notify" name="notify" switch role="switch" checked>
  </label>

  <fieldset class="segmented">
    <legend>Billing</legend>
    <label>
      <input type="radio" name="born-switch-billing" id="born-switch-monthly" value="monthly" checked>
      <span>Monthly</span>
    </label>
    <label>
      <input type="radio" name="born-switch-billing" id="born-switch-yearly" value="yearly">
      <span>Yearly</span>
    </label>
  </fieldset>
</div>
CSS
.stack {
  display: grid;
  gap: 20px;
  max-inline-size: 22rem;
}

.switch-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 14px 16px;
  border: 1px solid var(--border);
  border-radius: var(--radius-surface);
  background: var(--surface-raised);
  box-shadow: var(--shadow-sm);
  cursor: pointer;
}

.switch-row-text {
  display: grid;
  gap: 2px;
}

.switch-row-title {
  font-weight: 550;
}

.switch-row-desc {
  color: var(--ink-soft);
  font-size: 12.5px;
}

/* appearance: none discards the platform checkbox/switch rendering so the same look applies everywhere the switch attribute lands differently */
.switch {
  flex: none;
  inline-size: 42px;
  block-size: 26px;
  margin: 0;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  background: var(--hover);
  appearance: none;
  cursor: pointer;
  position: relative;
}

.switch::before {
  content: "";
  position: absolute;
  inset-block: 2px;
  inset-inline-start: 2px;
  inline-size: 20px;
  block-size: 20px;
  border-radius: 50%;
  background: var(--surface-raised);
  box-shadow: var(--shadow-sm);
}

.switch:checked {
  border-color: var(--accent);
  background: var(--accent);
}

.switch:checked::before {
  inset-inline-start: calc(100% - 22px);
  background: var(--label);
}

.segmented {
  position: relative;
  display: inline-grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  margin: 0;
  padding: 3px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--hover);
}

.segmented legend {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.segmented label {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  min-block-size: 32px;
  padding-inline: 18px;
  border-radius: 999px;
  color: var(--ink-soft);
  font-weight: 550;
  cursor: pointer;
}

.segmented label:has(:checked) {
  color: var(--ink);
}

.segmented label:has(:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.segmented input {
  position: absolute;
  inset: 0;
  margin: 0;
  opacity: 0;
}

/* The sliding indicator: one shared thumb, moved by which radio :has() reports checked */
.segmented::before {
  content: "";
  position: absolute;
  inset-block: 3px;
  inset-inline-start: 3px;
  inline-size: calc(50% - 3px);
  border-radius: 999px;
  background: var(--surface-raised);
  box-shadow: var(--shadow-sm);
}

.segmented:has(#born-switch-yearly:checked)::before {
  translate: 100% 0;
}

@media (prefers-reduced-motion: no-preference) {
  .switch,
  .switch::before {
    transition: background-color var(--duration) var(--ease), border-color var(--duration) var(--ease), inset-inline-start var(--duration) var(--ease);
  }

  .segmented::before {
    transition: translate var(--duration) var(--ease);
  }
}

Feedback form

Leave feedback

We only use this to follow up on your report.

At least 20 characters, so we have something to go on.

Native

2026

field-sizing: content + :user-valid/:user-invalid

field-sizing: content grows the textarea to fit what's typed, the auto-resize trick that used to need an input listener measuring scrollHeight on every keystroke. :user-valid and :user-invalid add per-field feedback that waits for a real interaction, not just page load.

Honest caveat: the inline messages are ::before generated content, so they're aria-hidden and decorative; the static, always-present hint underneath is what assistive tech actually reads. The dimmed submit button is a visual cue only, real blocking still comes from required.

View the code
HTML
<form class="form">
  <p class="lead">Leave feedback</p>

  <div class="row">
    <label for="born-forms-email">Email</label>
    <input
      class="input"
      type="email"
      id="born-forms-email"
      name="email"
      required
      aria-describedby="born-forms-email-hint"
    >
    <p class="hint" id="born-forms-email-hint">We only use this to follow up on your report.</p>
    <!-- Decorative: :user-valid/:user-invalid drive the text, so it stays silent until CSS support and real interaction agree. The hint above carries the description for assistive tech either way -->
    <p class="validation" aria-hidden="true"></p>
  </div>

  <div class="row">
    <label for="born-forms-message">Message</label>
    <!-- field-sizing: content grows the textarea with what you type, up to its max-height, no resize handle needed -->
    <textarea
      class="input textarea"
      id="born-forms-message"
      name="message"
      required
      minlength="20"
      aria-describedby="born-forms-message-hint"
    ></textarea>
    <p class="hint" id="born-forms-message-hint">At least 20 characters, so we have something to go on.</p>
    <p class="validation" aria-hidden="true"></p>
  </div>

  <p class="summary" role="alert">Fix the highlighted field before sending.</p>

  <button class="button button-primary" type="submit">Send feedback</button>
</form>
CSS
.form {
  display: grid;
  gap: 18px;
  max-inline-size: 24rem;
}

.lead {
  margin: 0;
  font-size: 16px;
  font-weight: 650;
}

.row {
  display: grid;
  gap: 6px;
}

.row label {
  color: var(--ink-soft);
  font-size: 13px;
  font-weight: 600;
}

.input {
  inline-size: 100%;
  padding: 9px 12px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-control);
  background: var(--surface-raised);
  color: var(--ink);
  font: inherit;
}

.input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.textarea {
  min-block-size: 2.6em;
  max-block-size: 10em;
  resize: vertical;
  line-height: 1.5;
}

/* field-sizing: content grows the box to fit what's typed, so the resize handle stops being necessary */
@supports (field-sizing: content) {
  .textarea {
    field-sizing: content;
    resize: none;
  }
}

.hint {
  margin: 0;
  color: var(--ink-soft);
  font-size: 12.5px;
}

.validation {
  margin: 0;
  min-block-size: 1em;
  font-size: 12.5px;
  font-weight: 600;
}

/* Only render a message once the user has actually interacted with the field */
.input:user-invalid ~ .validation::before {
  content: "That doesn't look right yet";
  color: light-dark(#c23b3b, #ff8080);
}

.input:user-valid ~ .validation::before {
  content: "Looks good";
  color: light-dark(#2f9e5c, #4bc27f);
}

.input:user-invalid {
  border-color: light-dark(#c23b3b, #ff8080);
}

.input:user-valid {
  border-color: light-dark(#2f9e5c, #4bc27f);
}

.summary {
  display: none;
  margin: 0;
  padding: 10px 12px;
  border: 1px solid light-dark(#c23b3b, #ff8080);
  border-radius: var(--radius-control);
  background: color-mix(in srgb, light-dark(#c23b3b, #ff8080) 10%, transparent);
  color: light-dark(#a32f2f, #ff8080);
  font-size: 13px;
  font-weight: 600;
}

/* A form-level summary appears only once a field has been touched and is still invalid */
.form:has(.input:user-invalid) .summary {
  display: block;
}

.button {
  justify-self: start;
}

/* Not truly disabled (typing an invalid value shouldn't trap focus): a visual hint only, backed by the browser's own required-field blocking on submit */
.form:has(.input:user-invalid) .button-primary {
  opacity: 0.55;
}

@media (prefers-reduced-motion: no-preference) {
  .input {
    transition: border-color var(--duration) var(--ease);
  }

  .button-primary {
    transition: opacity var(--duration) var(--ease);
  }
}

File tree

src
components
BornExhibit.astro
CodeBlock.astro
Placard.astro
exhibits
born
tree
demo.html
demo.css
demo.json
born.ts
5 more component folders
now-base.css
types.ts
styles
base.css
layout.css
tokens.css

Native

2026

Nested <details>/<summary> + ::details-content

Nested <details> and <summary> give a collapsible tree for free: keyboard toggling, state that survives without a data layer, and folders that open independently. ::details-content turns the hidden panel into a real box CSS can size and animate.

Honest caveat: this is a disclosure widget, not an ARIA tree. Tab moves between rows and enter toggles them, but arrow keys don't navigate the hierarchy the way a real tree role would. interpolate-size lets the open/close height tween instead of snapping.

View the code
HTML
<!-- A disclosure widget, not an ARIA tree: arrow keys don't move between rows, only tab and enter/space -->
<div class="tree">
  <details open>
    <summary>
      <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
      <svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V6a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/></svg>
      src
    </summary>
    <div class="tree-group">
      <details>
        <summary>
          <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
          <svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V6a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/></svg>
          components
        </summary>
        <div class="tree-group">
          <div class="tree-file"><svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>BornExhibit.astro</div>
          <div class="tree-file"><svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>CodeBlock.astro</div>
          <div class="tree-file"><svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>Placard.astro</div>
        </div>
      </details>

      <details open>
        <summary>
          <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
          <svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V6a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/></svg>
          exhibits
        </summary>
        <div class="tree-group">
          <details open>
            <summary>
              <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
              <svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V6a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/></svg>
              born
            </summary>
            <div class="tree-group">
              <details>
                <summary>
                  <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
                  <svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V6a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/></svg>
                  tree
                </summary>
                <div class="tree-group">
                  <div class="tree-file"><svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>demo.html</div>
                  <div class="tree-file"><svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>demo.css</div>
                  <div class="tree-file"><svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>demo.json</div>
                </div>
              </details>
              <div class="tree-file"><svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>born.ts</div>
              <div class="tree-file muted">5 more component folders</div>
            </div>
          </details>
          <div class="tree-file"><svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>now-base.css</div>
          <div class="tree-file"><svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>types.ts</div>
        </div>
      </details>

      <details>
        <summary>
          <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
          <svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20V6a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z"/></svg>
          styles
        </summary>
        <div class="tree-group">
          <div class="tree-file"><svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>base.css</div>
          <div class="tree-file"><svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>layout.css</div>
          <div class="tree-file"><svg class="icon type" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>tokens.css</div>
        </div>
      </details>
    </div>
  </details>
</div>
CSS
.tree {
  max-inline-size: 22rem;
  font-family: var(--font-code);
  font-size: 13px;
}

.tree summary {
  list-style: none;
  cursor: pointer;
}

.tree summary::-webkit-details-marker {
  display: none;
}

.tree summary,
.tree-file {
  display: flex;
  align-items: center;
  gap: 6px;
  min-block-size: 26px;
  padding-inline: 4px;
  border-radius: 6px;
}

.tree summary:hover,
.tree-file:hover {
  background: var(--hover);
}

.tree-file {
  color: var(--ink-soft);
}

.tree-file.muted {
  font-style: italic;
  opacity: 0.7;
}

/* Chevron: rotates open, folder icon: filled when open */
.tree summary .icon:first-child {
  flex: none;
  inline-size: 12px;
  block-size: 12px;
  color: var(--ink-soft);
}

.tree .icon.type {
  flex: none;
  inline-size: 14px;
  block-size: 14px;
  fill: color-mix(in srgb, var(--accent) 22%, transparent);
  stroke: var(--ink-soft);
}

.tree-file .icon.type {
  fill: none;
}

details[open] > summary .icon.type {
  stroke: var(--accent);
}

/* Indentation guide: a rule under each nested group, offset to sit under the parent's chevron */
.tree-group {
  margin-inline-start: 10px;
  padding-inline-start: 12px;
  border-inline-start: 1px solid var(--border);
}

@media (prefers-reduced-motion: no-preference) {
  .tree summary .icon:first-child {
    transition: rotate var(--duration) var(--ease);
  }

  details[open] > summary .icon:first-child {
    rotate: 90deg;
  }
}

@media (prefers-reduced-motion: reduce) {
  details[open] > summary .icon:first-child {
    rotate: 90deg;
  }
}

/* Animate the disclosure open/close, guarded twice: ::details-content to reach inside <details>, interpolate-size to let block-size tween to its natural height */
@supports selector(::details-content) and (interpolate-size: allow-keywords) {
  @media (prefers-reduced-motion: no-preference) {
    .tree details::details-content {
      interpolate-size: allow-keywords;
      block-size: 0;
      overflow: clip;
      opacity: 0;
      transition:
        block-size var(--duration) var(--ease),
        opacity var(--duration) var(--ease);
    }

    .tree details[open]::details-content {
      block-size: auto;
      opacity: 1;
    }
  }
}

Filters drawer

Filters

Status

Sort by

Tags

Native

2026

<dialog closedby="any"> + scroll-snap-type

command="show-modal" opens a real modal <dialog>: top layer, inert page behind it, focus trapped and returned on close, all without a script. closedby="any" adds backdrop-click and Escape dismissal on top of the Reset and Apply buttons, which close it with command="close".

It slides in from @starting-style with transition-behavior: allow-discrete keeping it in the top layer through the exit fade, becoming a bottom sheet on narrow screens and a side panel from 56rem up. Caveat: swipe-to-dismiss still needs a script.

View the code
HTML
<button class="button" type="button" commandfor="born-drawer-filters" command="show-modal">
  <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16"/><path d="M7 12h10"/><path d="M10 18h4"/></svg>
  Filters
</button>

<!-- closedby="any" adds light-dismiss (backdrop click, Escape) on top of the commandfor/command buttons below -->
<dialog id="born-drawer-filters" class="drawer" closedby="any" aria-labelledby="born-drawer-title">
  <div class="drawer-handle" aria-hidden="true"></div>

  <header class="drawer-header">
    <h3 id="born-drawer-title">Filters</h3>
    <button class="icon-button" type="button" commandfor="born-drawer-filters" command="close" aria-label="Close">
      <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
    </button>
  </header>

  <div class="drawer-body">
    <section class="drawer-section" id="born-drawer-status">
      <h4>Status</h4>
      <label><input type="checkbox" name="status" value="draft"> Draft</label>
      <label><input type="checkbox" name="status" value="review" checked> In review</label>
      <label><input type="checkbox" name="status" value="published" checked> Published</label>
    </section>

    <section class="drawer-section" id="born-drawer-sort">
      <h4>Sort by</h4>
      <label><input type="radio" name="born-drawer-sort" value="updated" checked> Last updated</label>
      <label><input type="radio" name="born-drawer-sort" value="created"> Date created</label>
      <label><input type="radio" name="born-drawer-sort" value="name"> Name</label>
    </section>

    <section class="drawer-section" id="born-drawer-tags">
      <h4>Tags</h4>
      <label><input type="checkbox" name="tags" value="css"> CSS</label>
      <label><input type="checkbox" name="tags" value="html"> HTML</label>
      <label><input type="checkbox" name="tags" value="a11y"> Accessibility</label>
      <label><input type="checkbox" name="tags" value="perf"> Performance</label>
    </section>
  </div>

  <footer class="drawer-footer">
    <button class="button" type="button" commandfor="born-drawer-filters" command="close">Reset</button>
    <button class="button button-primary" type="button" commandfor="born-drawer-filters" command="close">Apply</button>
  </footer>
</dialog>
CSS
.drawer {
  --drawer-pad: 20px;

  position: fixed;
  inset: auto 0 0;
  inline-size: 100%;
  max-inline-size: 100%;
  max-block-size: min(85vh, 32rem);
  margin: 0;
  padding: 0;
  border: 1px solid var(--border);
  border-block-end: 0;
  border-start-start-radius: 20px;
  border-start-end-radius: 20px;
  background: var(--surface-raised);
  box-shadow: var(--shadow-pop);
  color: var(--ink);
}

.drawer::backdrop {
  background: light-dark(rgb(15 20 27 / 40%), rgb(0 0 0 / 60%));
}

@supports (backdrop-filter: blur(1px)) {
  .drawer::backdrop {
    backdrop-filter: blur(3px);
  }
}

.drawer-handle {
  inline-size: 36px;
  block-size: 4px;
  margin: 10px auto 0;
  border-radius: 999px;
  background: var(--border-strong);
}

.drawer-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 12px var(--drawer-pad) 4px;
}

.drawer-header h3 {
  margin: 0;
  font-size: 17px;
  font-weight: 650;
}

.drawer-body {
  display: grid;
  gap: 20px;
  padding: 16px var(--drawer-pad);
  overflow-y: auto;
  overscroll-behavior: contain;
}

.drawer-section {
  display: grid;
  gap: 8px;
  scroll-snap-align: start;
}

.drawer-section h4 {
  margin: 0;
  color: var(--ink-soft);
  font-size: 13px;
  font-weight: 650;
}

.drawer-section label {
  display: flex;
  align-items: center;
  gap: 10px;
  min-block-size: 30px;
}

.drawer-footer {
  position: sticky;
  inset-block-end: 0;
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  padding: 14px var(--drawer-pad);
  border-block-start: 1px solid var(--border);
  background: var(--surface-raised);
}

/* The filter groups scroll as a snap container, so keyboard/scroll navigation settles on a section boundary instead of stopping mid-list */
@supports (scroll-snap-type: y proximity) {
  .drawer-body {
    scroll-snap-type: y proximity;
  }
}

@supports (scroll-initial-target: nearest) {
  #born-drawer-status {
    scroll-initial-target: nearest;
  }
}

/* Wide viewport: a side panel instead of a bottom sheet */
@media (width >= 56rem) {
  .drawer {
    inset: 0 0 0 auto;
    inline-size: min(22rem, 100%);
    max-block-size: 100%;
    border-block-end: 1px solid var(--border);
    border-inline-start: 1px solid var(--border);
    border-start-start-radius: 20px;
    border-end-start-radius: 20px;
    border-start-end-radius: 0;
  }

  .drawer-handle {
    display: none;
  }
}

@media (prefers-reduced-motion: no-preference) {
  .drawer,
  .drawer::backdrop {
    transition:
      opacity var(--duration) var(--ease),
      translate var(--duration) var(--ease),
      display var(--duration) allow-discrete,
      overlay var(--duration) allow-discrete;
    opacity: 0;
  }

  .drawer {
    translate: 0 100%;
  }

  .drawer[open],
  .drawer[open]::backdrop {
    opacity: 1;
  }

  .drawer[open] {
    translate: 0;
  }

  @starting-style {
    .drawer[open],
    .drawer[open]::backdrop {
      opacity: 0;
    }

    .drawer[open] {
      translate: 0 100%;
    }
  }

  @media (width >= 56rem) {
    .drawer {
      translate: 100% 0;
    }

    @starting-style {
      .drawer[open] {
        translate: 100% 0;
      }
    }
  }
}

Scroll-spy contents

Guide · Houseplants

Repotting a houseplant

Choose the pot

Go one size up, about two to four centimeters wider than the current pot. A much bigger pot holds more wet soil than the roots can use, and that is how root rot starts.

Whatever the material, make sure it has a drainage hole.

Water the day before

Moist soil holds together and slides out of the old pot in one piece. Dry soil crumbles, and bone-dry roots tear more easily when you handle them.

Skip fertilizer for now; the plant has enough to deal with this week.

Loosen the roots

Tip the pot sideways and ease the plant out by its base. If the roots circle the bottom, tease them apart with your fingers so they grow outward into the new soil.

Trim anything brown and mushy with clean scissors.

Settle it in

Add a layer of fresh mix, set the plant at the same depth as before and fill around it. Press gently rather than packing it down, then water until it runs from the hole.

Aftercare

Keep it out of direct sun for a week. A few droopy leaves are normal while the roots settle, and new growth is the sign that the move worked.

Native

2026

scroll-target-group: auto + :target-current

Highlighting the section you are reading used to take an IntersectionObserver, a map of ids to links and a class swapped by script. scroll-target-group turns plain fragment links into scroll markers, and the browser marks the current one with :target-current.

The links still work everywhere as ordinary anchors with smooth scrolling; only the live highlight is Chromium-only for now. Following a link also scrolls the page itself so the article comes into view.

View the code
HTML
<div class="doc">
  <div class="layout">
    <nav class="toc" aria-label="On this page">
      <p class="toc-title">On this page</p>
      <ol class="toc-list" role="list">
        <li><a href="#born-toc-pot">Choose the pot</a></li>
        <li><a href="#born-toc-water">Water the day before</a></li>
        <li><a href="#born-toc-roots">Loosen the roots</a></li>
        <li><a href="#born-toc-settle">Settle it in</a></li>
        <li><a href="#born-toc-after">Aftercare</a></li>
      </ol>
    </nav>

    <article class="article" tabindex="0" aria-label="Repotting a houseplant">
      <p class="eyebrow">Guide · Houseplants</p>
      <h4 class="article-title">Repotting a houseplant</h4>

      <section id="born-toc-pot" aria-labelledby="born-toc-pot-title">
        <h5 id="born-toc-pot-title">Choose the pot</h5>
        <p>Go one size up, about two to four centimeters wider than the current pot. A much bigger pot holds more wet soil than the roots can use, and that is how root rot starts.</p>
        <p>Whatever the material, make sure it has a drainage hole.</p>
      </section>

      <section id="born-toc-water" aria-labelledby="born-toc-water-title">
        <h5 id="born-toc-water-title">Water the day before</h5>
        <p>Moist soil holds together and slides out of the old pot in one piece. Dry soil crumbles, and bone-dry roots tear more easily when you handle them.</p>
        <p>Skip fertilizer for now; the plant has enough to deal with this week.</p>
      </section>

      <section id="born-toc-roots" aria-labelledby="born-toc-roots-title">
        <h5 id="born-toc-roots-title">Loosen the roots</h5>
        <p>Tip the pot sideways and ease the plant out by its base. If the roots circle the bottom, tease them apart with your fingers so they grow outward into the new soil.</p>
        <p>Trim anything brown and mushy with clean scissors.</p>
      </section>

      <section id="born-toc-settle" aria-labelledby="born-toc-settle-title">
        <h5 id="born-toc-settle-title">Settle it in</h5>
        <p>Add a layer of fresh mix, set the plant at the same depth as before and fill around it. Press gently rather than packing it down, then water until it runs from the hole.</p>
      </section>

      <section id="born-toc-after" aria-labelledby="born-toc-after-title">
        <h5 id="born-toc-after-title">Aftercare</h5>
        <p>Keep it out of direct sun for a week. A few droopy leaves are normal while the roots settle, and new growth is the sign that the move worked.</p>
      </section>
    </article>
  </div>
</div>
CSS
.doc {
  container-type: inline-size;
}

.layout {
  display: grid;
  grid-template-columns: 11.5rem minmax(0, 1fr);
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: var(--radius-surface);
  background: var(--surface-raised);
}

.toc {
  padding: 16px 12px;
  border-inline-end: 1px solid var(--border);
}

.toc-title {
  margin: 0 0 8px;
  padding-inline: 10px;
  color: var(--ink-soft);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.02em;
}

/* Turns the anchors into scroll markers for the article they point into */
.toc-list {
  display: grid;
  gap: 2px;
  margin: 0;
  padding: 0;
  list-style: none;
  scroll-target-group: auto;
}

.toc-list a {
  display: block;
  padding: 6px 10px;
  border-inline-start: 2px solid transparent;
  border-radius: 0 6px 6px 0;
  color: var(--ink-soft);
  font-size: 14px;
  line-height: 1.3;
  text-decoration: none;
}

.toc-list a:hover {
  background: var(--hover);
  color: var(--ink);
}

/* The link whose section is currently in view */
.toc-list a:target-current {
  border-inline-start-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  color: var(--ink);
  font-weight: 600;
}

.article {
  block-size: 22rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 16px 20px;
}

.article:focus-visible {
  outline-offset: -2px;
}

.eyebrow {
  margin: 0;
  color: var(--ink-soft);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.02em;
}

.article-title {
  margin: 2px 0 8px;
  font-size: 22px;
  letter-spacing: -0.02em;
  line-height: 1.15;
}

.article section {
  padding-block-start: 12px;
  scroll-margin-block-start: 4px;
}

/* Room for the last section to reach the top, so it can become current */
.article section:last-child {
  min-block-size: calc(22rem - 40px);
}

.article h5 {
  margin: 0 0 4px;
  font-size: 16px;
  letter-spacing: -0.01em;
}

.article p {
  max-inline-size: 38em;
  margin: 0 0 0.7em;
}

/* Narrow stages: the contents become a row of chips above the article */
@container (width < 34rem) {
  .layout {
    grid-template-columns: minmax(0, 1fr);
  }

  .toc {
    padding: 10px 12px;
    border-inline-end: 0;
    border-block-end: 1px solid var(--border);
  }

  .toc-title {
    padding-inline: 0;
  }

  .toc-list {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
  }

  .toc-list a {
    padding: 3px 9px;
    border: 1px solid var(--border);
    border-radius: 999px;
    font-size: 12.5px;
    white-space: nowrap;
  }

  .toc-list a:target-current {
    border-color: color-mix(in srgb, var(--accent) 45%, transparent);
  }

  .article {
    block-size: 18rem;
  }

  .article section:last-child {
    min-block-size: calc(18rem - 40px);
  }
}

@media (prefers-reduced-motion: no-preference) {
  .article {
    scroll-behavior: smooth;
  }

  .toc-list a {
    transition:
      background-color var(--duration) var(--ease),
      border-color var(--duration) var(--ease),
      color var(--duration) var(--ease);
  }
}

Reading progress

Scroll the article. The bar tracks how far you have read, and each step fades in as it enters the view.

Guide · 4 min read

Brewing a pour-over at home

A cone, a paper filter and a kettle are all you need. The rest is paying attention for about three minutes.

  1. Heat the water

    Bring it just off the boil, around 94 degrees. Rinse the paper filter with some of it to warm the cone and wash out the papery taste.

  2. Weigh and grind

    Use 15 grams of coffee for 250 grams of water. Grind it medium-fine, a little coarser than table salt.

  3. Let it bloom

    Pour twice the coffee's weight in water and wait thirty seconds. Fresh coffee bubbles up as it releases gas.

  4. Pour in slow circles

    Add the rest in steady spirals, keeping the level about halfway up the cone. Avoid pouring straight onto the paper.

  5. Taste and adjust

    The drawdown should finish near the three-minute mark. Sour means grind finer next time; bitter means grind coarser.

That is it. Write down what you changed, and the next cup will be better than this one.

Native

2026

animation-timeline: scroll() + view() with animation-range

A reading bar and reveal-on-scroll effects used to mean scroll listeners, IntersectionObserver and style writes on every frame. Scroll-driven animations tie an ordinary keyframe animation to scroll position, so the browser runs it off the main thread.

Everything sits behind @supports (animation-timeline: scroll()): without it the bar is hidden and the steps simply stay visible. The reveal also respects reduced motion, while the bar, which carries information, always tracks scrolling.

View the code
HTML
<p class="hint">Scroll the article. The bar tracks how far you have read, and each step fades in as it enters the view.</p>

<div class="page" tabindex="0" role="region" aria-label="Sample article: brewing a pour-over">
  <div class="progress" aria-hidden="true"></div>

  <article class="article">
    <p class="eyebrow">Guide · 4 min read</p>
    <h4>Brewing a pour-over at home</h4>
    <p class="lede">A cone, a paper filter and a kettle are all you need. The rest is paying attention for about three minutes.</p>

    <ol class="steps" role="list">
      <li class="step">
        <span class="step-number" aria-hidden="true">1</span>
        <h5>Heat the water</h5>
        <p>Bring it just off the boil, around 94 degrees. Rinse the paper filter with some of it to warm the cone and wash out the papery taste.</p>
      </li>
      <li class="step">
        <span class="step-number" aria-hidden="true">2</span>
        <h5>Weigh and grind</h5>
        <p>Use 15 grams of coffee for 250 grams of water. Grind it medium-fine, a little coarser than table salt.</p>
      </li>
      <li class="step">
        <span class="step-number" aria-hidden="true">3</span>
        <h5>Let it bloom</h5>
        <p>Pour twice the coffee's weight in water and wait thirty seconds. Fresh coffee bubbles up as it releases gas.</p>
      </li>
      <li class="step">
        <span class="step-number" aria-hidden="true">4</span>
        <h5>Pour in slow circles</h5>
        <p>Add the rest in steady spirals, keeping the level about halfway up the cone. Avoid pouring straight onto the paper.</p>
      </li>
      <li class="step">
        <span class="step-number" aria-hidden="true">5</span>
        <h5>Taste and adjust</h5>
        <p>The drawdown should finish near the three-minute mark. Sour means grind finer next time; bitter means grind coarser.</p>
      </li>
    </ol>

    <p class="outro">That is it. Write down what you changed, and the next cup will be better than this one.</p>
  </article>
</div>
CSS
.hint {
  margin: 0 0 0.75rem;
  color: var(--ink-soft);
  font-size: 13px;
}

.page {
  block-size: 22rem;
  overflow-y: auto;
  overscroll-behavior: contain;
  border: 1px solid var(--border);
  border-radius: var(--radius-surface);
  background: var(--surface-raised);
}

.page:focus-visible {
  outline-offset: 3px;
}

/* Hidden unless scroll-driven animations can drive it */
.progress {
  display: none;
}

.article {
  padding: 20px 20px 28px;
}

.eyebrow {
  margin: 0;
  color: var(--ink-soft);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.02em;
}

.article h4 {
  margin: 2px 0 8px;
  font-size: 22px;
  letter-spacing: -0.02em;
  line-height: 1.15;
}

.lede,
.outro {
  max-inline-size: 36em;
  margin: 0;
}

.outro {
  color: var(--ink-soft);
}

.steps {
  display: grid;
  gap: 10px;
  margin: 20px 0;
  padding: 0;
  list-style: none;
}

.step {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: 2px 14px;
  padding: 14px 16px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface);
  box-shadow: var(--shadow-sm);
}

.step-number {
  display: grid;
  grid-row: span 2;
  place-items: center;
  inline-size: 28px;
  block-size: 28px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  color: var(--accent);
  font-size: 13px;
  font-weight: 700;
}

.step h5 {
  margin: 3px 0 0;
  font-size: 16px;
  letter-spacing: -0.01em;
}

.step p {
  margin: 0;
  color: var(--ink-soft);
  font-size: 14px;
}

@supports (animation-timeline: scroll()) {
  /* Sticks to the top of the scroller and grows with its scroll position */
  .progress {
    display: block;
    position: sticky;
    inset-block-start: 0;
    z-index: 1;
    block-size: 3px;
    margin-block-end: -3px;
    background: var(--accent);
    transform-origin: 0 50%;
    animation: born-progress-grow linear both;
    animation-timeline: scroll(nearest block);
  }

  @keyframes born-progress-grow {
    from {
      scale: 0 1;
    }

    to {
      scale: 1 1;
    }
  }

  @media (prefers-reduced-motion: no-preference) {
    /* Each step reveals over the first part of its entry into the scrollport */
    .step {
      animation: born-progress-reveal linear both;
      animation-timeline: view();
      animation-range: entry 10% entry 90%;
    }

    @keyframes born-progress-reveal {
      from {
        opacity: 0;
        translate: 0 16px;
        scale: 0.97;
      }
    }
  }
}

Radial menu

Today

3 notes
  • Call the plumber Before Thursday
  • Book table for Friday Four people, 8 pm
  • Return library books Due tomorrow

Native

2026

sibling-index() + sibling-count() with cos()/sin(), opened by popovertarget

Fanning items around a button used to mean script measuring children, or a hand-written :nth-child rule for every item. sibling-index() and sibling-count() let each item compute its own angle and stagger, so the arc re-spaces itself when you add or remove one.

The popover handles toggling, Escape, light dismiss and focus order; anchor positioning centers the arc on the button. The angle is a registered custom property, so it resolves on each item and its label inherits the value instead of recounting.

View the code
HTML
<div class="app">
  <div class="app-head">
    <h4>Today</h4>
    <span class="app-count">3 notes</span>
  </div>

  <ul class="notes" role="list">
    <li>
      <span class="note-title">Call the plumber</span>
      <span class="note-meta">Before Thursday</span>
    </li>
    <li>
      <span class="note-title">Book table for Friday</span>
      <span class="note-meta">Four people, 8 pm</span>
    </li>
    <li>
      <span class="note-title">Return library books</span>
      <span class="note-meta">Due tomorrow</span>
    </li>
  </ul>

  <button class="fab" type="button" popovertarget="born-radial-menu" aria-label="Quick actions">
    <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
  </button>

  <!-- Each item places itself on the arc from its own sibling-index() -->
  <div id="born-radial-menu" class="menu" popover role="group" aria-label="Quick actions">
    <ul role="list">
      <li>
        <button class="action" type="button" popovertarget="born-radial-menu" popovertargetaction="hide">
          <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M12 12v6"/><path d="M9 15h6"/></svg>
          <span class="label">New note</span>
        </button>
      </li>
      <li>
        <button class="action" type="button" popovertarget="born-radial-menu" popovertargetaction="hide">
          <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z"/><circle cx="12" cy="13" r="3"/></svg>
          <span class="label">Photo</span>
        </button>
      </li>
      <li>
        <button class="action" type="button" popovertarget="born-radial-menu" popovertargetaction="hide">
          <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
          <span class="label">Link</span>
        </button>
      </li>
      <li>
        <button class="action" type="button" popovertarget="born-radial-menu" popovertargetaction="hide">
          <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/></svg>
          <span class="label">Voice memo</span>
        </button>
      </li>
      <li>
        <button class="action" type="button" popovertarget="born-radial-menu" popovertargetaction="hide">
          <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3 2 6"/><path d="m22 6-3-3"/></svg>
          <span class="label">Reminder</span>
        </button>
      </li>
    </ul>
  </div>
</div>
CSS
/* Registered, so each item's angle is computed once on the <li> and inherited as a value */
@property --born-radial-angle {
  syntax: '<angle>';
  inherits: true;
  initial-value: 0deg;
}

.app {
  position: relative;
  max-inline-size: 26rem;
  min-block-size: 20rem;
  margin-inline: auto;
  padding: 18px;
  border: 1px solid var(--border);
  border-radius: var(--radius-surface);
  background: var(--surface-raised);
}

.app-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin-block-end: 12px;
}

.app-head h4 {
  margin: 0;
  font-size: 22px;
  letter-spacing: -0.02em;
}

.app-count {
  color: var(--ink-soft);
  font-size: 13px;
}

.notes {
  display: grid;
  margin: 0;
  padding: 0;
  list-style: none;
}

.notes li {
  display: grid;
  padding-block: 10px;
  border-block-start: 1px solid var(--border);
}

.note-title {
  font-weight: 550;
}

.note-meta {
  color: var(--ink-soft);
  font-size: 13px;
}

.fab {
  position: absolute;
  inset-block-end: 18px;
  inset-inline-end: 18px;
  display: grid;
  place-items: center;
  inline-size: 52px;
  block-size: 52px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--ink);
  box-shadow: var(--shadow-pop);
  color: var(--label);
  cursor: pointer;
  anchor-name: --born-radial-fab;
}

.fab .icon {
  inline-size: 22px;
  block-size: 22px;
  stroke-width: 2;
}

.app:has(.menu:popover-open) .fab .icon {
  rotate: 45deg;
}

/* The notes step back while the actions are out */
.app:has(.menu:popover-open) .notes {
  opacity: 0.3;
}

/* A zero-size popover centered on the button: the arc is drawn around that point */
.menu {
  --radius: 8.5rem;

  inline-size: 0;
  block-size: 0;
  overflow: visible;
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink);
}

.menu ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

/* A quarter circle from left (180deg) to up (270deg), spaced by the item count */
.menu li {
  --born-radial-angle: calc(180deg + (sibling-index() - 1) * 90deg / (sibling-count() - 1));

  position: absolute;
  inset-block-start: -22px;
  inset-inline-start: -22px;
  translate:
    calc(cos(var(--born-radial-angle)) * var(--radius))
    calc(sin(var(--born-radial-angle)) * var(--radius));
}

.action {
  display: grid;
  place-items: center;
  inline-size: 44px;
  block-size: 44px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 50%;
  background: var(--surface-raised);
  box-shadow: var(--shadow-pop);
  color: var(--ink);
  cursor: pointer;
}

.action:hover {
  background: color-mix(in srgb, var(--ink) 6%, var(--surface-raised));
}

.action .icon {
  inline-size: 18px;
  block-size: 18px;
}

/* The name stays readable to assistive tech and appears on hover or focus, pushed outward along the arc */
.label {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  padding: 4px 8px;
  border-radius: 6px;
  background: var(--ink);
  color: var(--label);
  font-size: 12px;
  font-weight: 600;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  translate:
    calc(-50% + cos(var(--born-radial-angle)) * (50% + 30px))
    calc(-50% + sin(var(--born-radial-angle)) * (50% + 30px));
}

.action:is(:hover, :focus-visible) .label {
  opacity: 1;
}

@supports (anchor-name: --a) {
  .menu {
    position-anchor: --born-radial-fab;
    position-area: center;
    inset: auto;
    margin: 0;
  }
}

@media (prefers-reduced-motion: no-preference) {
  .fab .icon {
    transition: rotate 220ms var(--ease);
  }

  .notes {
    transition: opacity 220ms var(--ease);
  }

  .label {
    transition: opacity 120ms var(--ease);
  }

  /* The popover stays rendered while the items fly back in */
  .menu {
    transition:
      display 360ms allow-discrete,
      overlay 360ms allow-discrete;
  }

  .menu li {
    transition:
      translate 240ms var(--ease),
      opacity 200ms var(--ease),
      scale 240ms var(--ease);
    transition-delay: calc((sibling-count() - sibling-index()) * 30ms);
  }

  .menu:not(:popover-open) li {
    translate: 0 0;
    opacity: 0;
    scale: 0.4;
  }

  .menu:popover-open li {
    transition-delay: calc((sibling-index() - 1) * 30ms);
  }

  @starting-style {
    .menu:popover-open li {
      translate: 0 0;
      opacity: 0;
      scale: 0.4;
    }
  }
}