Heads up — this is a 2018 piece. Back then container queries didn’t exist, CSS custom properties were still a treat, and resizing a pane without JavaScript took some imagination. Today you’d reach for @container, resize: horizontal, or a tiny component, and move on. I’m leaving the article as it was: it’s a snapshot of what we played with before the platform caught up, and half the fun is seeing which hacks the spec eventually absorbed.
This morning a design landed in my inbox.
Two panes, draggable separator, content that adapts to its own width. Nothing flashy — but there’s a question buried in it that I find interesting: how much of this does the browser already know how to do, if I get out of the way?
That’s the spirit of this article. It’s not a recipe to ship; it’s an afternoon of tinkering. I’ll start from native elements and CSS, see how far they take me, and only reach for JavaScript when the platform refuses to budge.
The point isn’t to be clever. It’s to listen to what <input type="range"> and <object> already are, and let them carry as much weight as they will.
1. Resizable panes
So: which native element already behaves like a draggable separator?
The obvious candidate:
<input type="range" min="0" max="100" value="10">
It already drags, it already takes keyboard input, it’s well supported, and it can be styled (a bit). All of that for free.
So the experiment is: can its [value] attribute drive the width of its siblings, purely in CSS?
<main class="splitted-container">
<input class="splitter" type="range"
min="0" max="100" step="25" value="25" />
<article class="left-pane">Left</article>
<aside class="right-pane">Right</aside>
</main>
.splitter[value="0"] ~ .left-pane { flex-basis: 0; }
.splitter[value="0"] ~ .right-pane { flex-basis: 100%; }
.splitter[value="25"] ~ .left-pane { flex-basis: 25%; }
.splitter[value="25"] ~ .right-pane { flex-basis: 75%; }
.splitter[value="50"] ~ .left-pane { flex-basis: 50%; }
.splitter[value="50"] ~ .right-pane { flex-basis: 50%; }
.splitter[value="75"] ~ .left-pane { flex-basis: 75%; }
.splitter[value="75"] ~ .right-pane { flex-basis: 25%; }
.splitter[value="100"] ~ .left-pane { flex-basis: 100%; }
.splitter[value="100"] ~ .right-pane { flex-basis: 0; }
The slider starts at value="25" and the panes split 25/75 to match. So far so good. But drag the slider and watch: the layout stays at 25/75 regardless. The slider’s value property updates — but its [value] attribute in the DOM does not. CSS only sees the attribute.
Worth pausing on rather than glossing over: as a static starting point, this isn’t useless. If you don’t actually need the panes to move, the markup above renders fine; you’d just add [disabled] on the splitter so the cursor doesn’t promise something the page won’t deliver.
But we wanted them to move. So we need to push the slider’s runtime value back into a DOM attribute.
A line of JavaScript
The smallest possible nudge: an inline oninput that copies the property to the attribute.
<main class="splitted-container">
<input class="splitter" type="range"
min="0" max="100" step="25" value="25"
oninput="this.setAttribute('value', this.value)" />
<article class="left-pane">Left</article>
<aside class="right-pane">Right</aside>
</main>
I’ve used step="25" so the demo only needs five rules. In a real version you’d let a preprocessor enumerate every integer percent, or pick a coarser step that fits your design. Either way, the bookkeeping is hand-written.
If we accept JavaScript and target a modern browser, there’s a much shorter path: a CSS custom property that the slider writes to directly.
<main class="splitted-container" style="--split: 25%">
<input class="splitter" type="range"
min="0" max="100" value="25"
oninput="this.parentNode.style.setProperty('--split', this.value + '%')" />
<article class="left-pane">Left</article>
<aside class="right-pane">Right</aside>
</main>
.left-pane { flex-basis: var(--split, 50%); }
.right-pane { flex-basis: calc(100% - var(--split, 50%)); }
No preprocessor, no enumerated rules — the platform now does the bookkeeping.
One small wart worth flagging before we move on: an <input type="range"> infers its orientation from its dimensions — wider than tall and it’s horizontal, otherwise vertical. The moment your container doesn’t span the full viewport, you may find yourself reaching for transform to coax the slider into the orientation you want.
A note on what’s not here: the original 2018 version had a second variant built on <input type="radio"> instead of a slider — same idea, but the sibling rules keyed off [value="…"]:checked + … instead of an enumerated value attribute. It worked, and you got keyboard navigation for free (arrow keys move between radios), though obviously not the draggable mouse behaviour. I’ve cut it from this revision: now that custom properties are everywhere, the enumeration trick is mostly a curiosity, and the principle transfers cleanly to anything with a :checked-able state. Mentioning it here so the omission is deliberate, not amnesia.
2. The element queries we never got
CSS authors have wanted them forever: media queries that respond to an element’s own size, not the viewport’s. The spec keeps almost arriving and then not arriving, so let’s see what we can borrow in the meantime.
The clue comes from SVG. When you embed an SVG as an external document — say, via the src of an <img> — it’s a self-contained document with its own viewport. A media query inside that SVG responds to its own box. The page’s viewport is irrelevant to it.
So the question becomes: are there HTML elements with the same property? Elements that host a separate document inside the page, with its own viewport?
Two candidates: <iframe> and <object>. Both work, identically as far as I can tell. I’ll use <object> — partly because the syntax for inlining content via [data] is convenient, partly because the word “object” reads as “a self-contained thing in here,” which is exactly what we’re after.
Every code preview on this page is, in fact, an <iframe srcdoc> doing the same containment trick — scroll up and pick any one, and you’re looking at an isolated document responding to its own width.
The trick is to put the entire sub-document — markup, styles, media query and all — into the [data] attribute as a data:text/html,… URL. The browser then renders it as if it had been served from elsewhere, and its @media queries respond to the <object>’s own dimensions.
<div class="resizable">
<object data="data:text/html,
<body>
Resize me!
<style>
body {
padding: .5em;
background: linear-gradient(
to right,
transparent 20em,
rgba(0,0,0,.1) 0
);
}
@media (max-width: 20em) {
body { color: crimson; }
}
</style>
</body>
"></object>
</div>
.resizable {
resize: horizontal;
overflow: hidden;
border: 2px dashed grey;
width: 30em;
max-width: 100%;
height: 6em;
}
.resizable > object {
display: block;
width: 100%;
height: 100%;
}
<object> doesn’t care what the page’s viewport is doing.Hand-encoding HTML inside an attribute is, admittedly, the kind of thing a future component should hide (<ResponsiveObject>…</ResponsiveObject> more or less writes itself). But the underlying mechanism is two HTML elements arranged in a way the spec didn’t have in mind, and that’s the whole point.
Both halves at once
Now we put them together — three layers of “respond to your size” in one little widget. The page itself decides the article column. The slider you drag drives the panes. And inside each pane, an <object> viewport carries its own media queries.
<input class="splitter" type="range"
min="0" max="100" value="50"
oninput="
var p = this.parentNode;
p.style.setProperty('--split', this.value + '%');
// let's go a little bit further
p.dataset.split = this.value < 10 ? 'left-out'
: this.value > 90 ? 'right-out'
: 'mid';
" />
.left-pane { flex-basis: var(--split, 50%); }
.right-pane { flex-basis: calc(100% - var(--split, 50%)); }
.splitted-container[data-split="left-out"] > .left-pane,
.splitted-container[data-split="right-out"] > .right-pane {
display: none;
}
.splitted-container[data-split="left-out"] > .right-pane,
.splitted-container[data-split="right-out"] > .left-pane {
flex-basis: 100%;
}
.splitted-container[data-split="right-out"] > .left-pane {
margin-right: 1em;
}
.splitted-container[data-split="left-out"] > .right-pane {
margin-left: 1em;
}
/* inside each <object>'s <style>: */
:root { --pane-color: steelblue; --pane-label: 'wide'; }
@media (max-width: 22em) {
:root { --pane-color: chocolate; --pane-label: 'medium'; }
}
@media (max-width: 11em) {
:root { --pane-color: crimson; --pane-label: 'narrow'; }
}
body {
margin: 0; height: 100%; box-sizing: border-box;
display: flex; align-items: center; justify-content: center;
font: 600 1.4em system-ui, sans-serif;
color: var(--pane-color);
box-shadow: inset 0 0 0 .5em var(--pane-color);
}
body::before { content: var(--pane-label); }
Three layers of responsiveness, each on its own scale, none of them aware of the others. The drag is the user’s. The whole-pane vanishing trick at the extremes is the page’s CSS reading a data-split attribute. The internal reflow inside each pane is its own document, with its own @media queries that respond to its own width — content drops out as it gets squeezed, the way you’d build a real component.
The slider thumb keeps its native behaviour but gets a coat of paint via two SCSS mixins emitting one rule per vendor prefix; the recipe on styling input ranges walks through the pattern.
Closing thoughts
Two things stay with me from this afternoon.
The first is how much the browser already does, if you let it. <input type="range"> came with a draggable thumb, keyboard handling, focus, and accessibility. <object> came with its own viewport. We didn’t build either of those — we noticed them, and built a thin layer of CSS around them. That’s a different kind of work than writing components from scratch, and I think it’s underrated.
The second is the gap the <object> trick was filling. The reason there’s no clean spec for element queries is that the browser layout engine resolves sizes top-down — children depending on their own size creates loops. The way out is a contained sub-context whose layout doesn’t leak outward, which is what an SVG document already had, and what an <object> accidentally provides. Years later, container queries shipped, and the underlying idea is the same: declare a containment boundary, then query inside it. The hack and the spec turned out to be pointing at the same thing.