Native components in 2026: what HTML and CSS already do on their own
Modal, dropdown, tooltip, carousel and much more without a single line of JavaScript, side by side with the 2014 hacks đ
On this post
In 2014 I wrote a post here on the blog asking whether itâs possible to use components built with CSS only (in Portuguese).
In the conclusion I said YES and dropped this line:
I wouldnât be surprised if this becomes a trend going forward.
12 years later I went back to that project and I can say it passed the test of time. Both the CSS + HTML combo and the browser evolved, and now we can do all of this (and a lot more!) natively, in an extremely performant and accessible way, with animations as a bonus.
So this post is about exactly that: what HTML and CSS already do on their own in 2026, using the 2014 project as proof, component by component.
A bit of history
It all started back in 2012 with a Cartman made with CSS only. A silly experiment, but thatâs where I started looking for CSS solutions I could actually use in real projects.
In 2014 that became Pure CSS Components: 6 interface components without a single line of JavaScript.
- Carousel
- Collapse/Accordion
- Dropdown
- Modal
- Tab
- Tooltip
All of it built with hidden checkbox and radio inputs (and in the beginning also with the :target pseudo-class). Checked and unchecked was our âstate managementâ back then.
And the best part (!!): a lot of people really used it in production on real projects. On GitHub it got close to 700 stars, which was a lot for a project that was born from a silly experiment and the curiosity of a dev still early in his career exploring the possibilities.
Now the project was rebuilt from scratch with a very simple idea: every 2014 component was preserved exactly as it was and shows up side by side with its native 2026 version. Itâs like a âbefore and afterâ of how browsers and CSS evolved.
2014 hack vs. 2026 native
Collapse
In 2014 it was an <input type="checkbox" hidden> with a <label> and the sibling selector ~ to reveal the content. If you wanted an accordion, you switched to type="radio" with the same name.
Back in 2021 I already talked here about the badass <details> and <summary>, but since then theyâve gotten even more powerful.
Now the exclusive accordion (where only one stays open at a time) is just a matter of putting the same name on all the <details>:
<!-- Same name = exclusive group: opening one closes the others -->
<div class="collapse">
<details name="now-accordion" open>
<summary>Install</summary>
<div class="panel">
<p>Clone the repository and run <code>pnpm install</code>.</p>
</div>
</details>
<details name="now-accordion">
<summary>Run locally</summary>
<div class="panel">
<p>Start the dev server with <code>pnpm dev</code>.</p>
</div>
</details>
</div>Notice that itâs literally the same radio with name trick from 2014, except now HTML itself does it for you đ
And the open and close animation, which was always the Achillesâ heel of <details>, can now be done with the ::details-content pseudo-element:
/* Animate the native content slot to and from height: auto */
@media (prefers-reduced-motion: no-preference) {
@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;
}
}
}interpolate-size: allow-keywords is what lets you animate to auto, something we dreamed about for years. And notice the @supports: if the browser doesnât know ::details-content, the collapse keeps working, just without the animation. Same thing for people who prefer less motion, thanks to prefers-reduced-motion.
Modal
In the first 2014 post (in Portuguese) the modal used :target, but the version preserved on the site is the one with a hidden checkbox and a bunch of <label>s pointing to it:
<label class="btn" for="then-modal-one">Modal!</label>
<div class="modal">
<input class="modal-open" id="then-modal-one" type="checkbox" hidden>
<div class="modal-wrap" aria-hidden="true" role="dialog">
<label class="modal-overlay" for="then-modal-one"></label>
<div class="modal-dialog">
<div class="modal-header">
<h2>Modal in CSS?</h2>
<label class="btn-close" for="then-modal-one" aria-hidden="true">Ă</label>
</div>
...
</div>
</div>
</div>The button that opens it is a <label>, the dark backdrop that closes it is a <label>, the âĂâ is a <label>⊠all of it to check and uncheck a single checkbox đ
It worked, but focus, Esc and screen readers were left to luck.
Now we have <dialog> with invoker commands:
<button class="button" type="button" commandfor="now-modal" command="show-modal">
Discard changes
</button>
<dialog id="now-modal" class="modal" closedby="any" aria-labelledby="now-modal-title">
<h3 id="now-modal-title">Discard unsaved changes?</h3>
<footer class="modal-footer">
<!-- autofocus: the safe choice gets focus when the dialog opens -->
<button class="button" type="button" commandfor="now-modal" command="close" autofocus>Keep editing</button>
<button class="button button-primary" type="button" commandfor="now-modal" command="close">Discard</button>
</footer>
</dialog>commandfor points to the <dialog>âs id and command="show-modal" says what to do. closedby="any" makes the modal close when you click outside or press Esc. Focus, backdrop, top layer, all for free.
And to animate it we use @starting-style, which defines the elementâs initial state at the moment it appears:
@media (prefers-reduced-motion: no-preference) {
.modal,
.modal::backdrop {
transition:
opacity var(--duration) var(--ease),
display var(--duration) allow-discrete,
overlay var(--duration) allow-discrete;
opacity: 0;
}
.modal[open],
.modal[open]::backdrop {
opacity: 1;
}
@starting-style {
.modal[open],
.modal[open]::backdrop {
opacity: 0;
}
}
}display and overlay with allow-discrete keep the <dialog> in the top layer until the exit fade finishes. Something we didnât even dream of in 2014 đ
Dropdown
In 2014 it was yet another hidden checkbox. Now itâs the Popover API with popovertarget, and positioning is handled by CSS anchor positioning, which pins the menu to the button without you having to calculate anything.
And the hover variant, which used to be a :hover in CSS, now uses interestfor.
Tooltip
The tooltip uses popover="hint" together with interestfor and anchor positioning. Itâs the kind of thing we installed an entire library for, for years.
Tab
The native version uses exclusive <details name> visually arranged as tabs.
But here I have to be honest: without JavaScript you canât implement the tabs pattern with arrow key navigation. It works and itâs accessible as a group of <details>, but itâs not the full tabs pattern.
Carousel
The 2014 carousel was the most âcreativeâ one (read: hack). A radio before each item, three pairs of <label>s for the arrows and that monstrous nth-child selector for the indicators.
Now itâs scroll-snap to lock the items, ::scroll-button() for the arrows and ::scroll-marker for the indicators. All generated by the browser itself.
Born native
Besides the 6 original components, I created a new section called Born native with 10 components that didnât even have a 2014 version, because it simply wasnât possible with CSS alone:
- Custom select with
appearance: base-selectand::picker(select). Yes, we can finally style the<select>đ„ł - Switch and segmented control using
:has() - Forms with
field-sizing: contentand:user-invalid - File tree with nested
<details> - Filter drawer with
<dialog> - Sticky header with scroll-state container queries
- Table of contents with scroll-spy using
scroll-target-groupand:target-current - Reading progress bar with scroll-driven animations
- Radial menu with
sibling-index(),sibling-count(),cos()andsin() - Gallery with cross-document view transitions
And one detail I decided to keep: the siteâs light/dark theme button (âlightsâ) uses exactly the 2014 trick, a hidden checkbox together with :has():
/* Lights toggle: a hidden checkbox flips the scheme, the 2014 way */
@media (prefers-color-scheme: light) {
:root:has(#lights:checked) {
color-scheme: dark;
}
}
@media (prefers-color-scheme: dark) {
:root:has(#lights:checked) {
color-scheme: light;
}
}A tribute to the original project đ
What about performance?
The big question with this kind of component is performance. So I ran Lighthouse on the siteâs home page (production build, local preview):
- Mobile: Performance 90, LCP 2.9 s, Total Blocking Time 0 ms and CLS 0
- Desktop: Performance 100, LCP 0.6 s, Total Blocking Time 0 ms and CLS 0.008
- Both: Accessibility 97, Best Practices 100 and SEO 100
And what goes to the browser:
- JavaScript: 0 files, 0 KB
- CSS: two files, about 2.3 KB and 1.4 KB gzipped
- Total page weight: 301 KiB, mostly self-hosted fonts and images
For comparison: jQuery 3.7.1 alone is 87 KB minified (about 30 KB gzipped), and thatâs before any plugin. And the typical 2014 stack was exactly that, jQuery + one plugin for each component.
But the gain isnât just about weight. When the browser does the work:
- Thereâs no JavaScript to download, parse and execute
- Nothing runs on the main thread to manage state, opening and closing is handled by the browser itself
<dialog>and popover live in the top layer, so thez-indexwar is over- Scroll-driven animations and scroll snapping can run off the main thread
- Thereâs no hydration
- It works with JavaScript disabled (and yes, I tested it)
- Less code means fewer places for a bug to hide
- Accessibility comes out of the box: focus,
Escand light dismiss
Thatâs also why I decided to remove Google Tag Manager and the ads the site had before. The site has no analytics, nothing tracking you, and thatâs what made it truly zero JavaScript. If you want to support the project, thereâs GitHub Sponsors.
Now, being honest: the home page HTML is huge, about 1.37 MB raw (76 KB gzipped). Thatâs because each demoâs code already comes with syntax highlighting and is embedded in the page, even with the âview codeâ panels closed. The 90 on mobile basically comes from the first paint of a very long page, not from interactivity. Itâs on the improvement list, like loading that code only when someone asks for it.
Itâs not all roses
Many of these features currently only work in Chromium-based browsers.
Thatâs why the site lists the support for each feature and every component has a usable fallback. If the browser doesnât support it, you donât end up with a broken component, just a simpler version.
This is not a âyou can use everything in production tomorrowâ. Itâs a snapshot of where browsers are heading.
Zero JavaScript, but 100% vibe coding
Thereâs a pretty funny irony in this whole story:
a project whose rule is not having a single line of JavaScript only came back to life because I built it entirely with vibe coding
But first I have to make a confession: I put off touching this project for years (decades!) haha
Abandoned software doesnât sit still, it rots. I went almost 10 years without any major improvement and, when I finally took a calm look, I found dead placeholder images, a rawgit CDN that no longer exists, Bower installation instructions and 39 vulnerabilities in npm audit, 1 of them critical. That feeling of opening a drawer you havenât touched in 10 years.
It wasnât just updating dependencies, it was redoing the whole project, and in my head that was weeks of work that never fit into my life these days. Next week lasted a good few years đ
In 2026, building this kind of project is practically trivial with LLMs. So I finally redid the whole project, design included, in a few hours using my faithful sidekick Claudinho Code, with that planning, skills, roles and subagents setup Iâve covered in n posts, like my AI stack for devs, and on social media.
And I want to be 100% transparent here: I didnât write a single line of code of this project by hand. Not HTML, not CSS, not config. Everything came out of a conversation with Claude Code, in a long session of a few hours. My job was to steer, review and decide.
How it went in practice
It started in plan mode: an analysis of the old stack and a written plan that I read, adjusted and approved before any line of code. No blindly vibe coding in the dark.
Then came the execution in atomic commits and PRs, with subagents in parallel: Opus for design, research and the more complex parts, Sonnet for the more mechanical tasks.
To decide which âBorn nativeâ components could be built without JavaScript, a subagent used the octocode MCP to dig through MDN, web-features and specs, and checked real browser support on webstatus.dev. Thatâs context engineering in practice: the LLM doesnât guess, it goes after the source.
And every change was tested in a real browser with Playwright: light and dark mode, desktop and mobile, keyboard and mouse, and even a round with JavaScript disabled to prove the zero JS was real. On top of that, lint, type-check, build and self-review agents before each PR.
The AI got it wrong (and also caught mistakes)
Not everything was pretty, and thatâs exactly why this whole process exists:
- The ported 2014 CSS came out 60% bigger, because the
rems were based on the oldfont-size: 62.5%on the root. A review round caught it. - An agent locked in a browser support version that webstatus.dev proved wrong
- The link color failed the contrast test and had to be fixed
Nothing out of this world, a human dev would do way dumber stuff. The difference is having a process that catches it before it goes to production, and keeping an eye on things is still my job.
And there was one nobody remembered: the production deploy on Netlify had been stuck since October 2023, with automatic publishing turned off. Meaning nothing had gone live in years. Claude found that and unblocked it through the Netlify CLI/MCP. As a bonus, the 7 old zip download URLs now redirect to a GitHub release, so nobody lands on a broken link.
What was my call
At the end of the day Iâm still the one deciding, so:
- Astro + pnpm + plain CSS (I did consider SCSS, but plain CSS in 2026 handles everything)
- Keeping the 2014 components intact
- The âThen vs Nowâ side by side idea
- Lowercase typography
- The favicon with the puzzle piece emoji đ§©
- Removing Google Tag Manager and any line of JS
- Asking for this post and the cover, which was also generated in the same session (I rewrote almost all the text, of course đ )
In other words, my work here was exactly whatâs expected of a software engineer in 2026: maintaining my own AI ecosystem (well-configured skills, rules, MCPs and subagents), planning and approving the plan before any code, deciding architecture and stack, reviewing every output while distrusting everything that wasnât verified, and saying no when a suggestion doesnât make sense.
As I said in the post about what to expect from 2026, we wonât write code by hand anymore, but increasingly guide, review and orchestrate. And as I always say, regardless of the tooling you use, the person responsible for every delivery is still YOU.
Conclusion
In 2014 we used a hidden checkbox because it was the only way. In 2026 HTML and CSS do this natively, with accessibility, focus and animation as a bonus, and without sending 1 KB of JavaScript to the user.
That âtrendâ I predicted came true, except the browser got there before I did.
And after years of putting it off, it took just a few hours with an AI by my side to bring this project back to life. Hereâs a lesson for that side project thatâs abandoned in your drawer too đ
- Pure CSS Components
- Code on GitHub
- The original 2014 post (in Portuguese)