Implementation: Resizable Grid
The grid of the web
Grid layout has been discussed for inclusion in the CSS standard since 2007, but started to be popular only a decade later, when all major browsers finally supported it unprefixed.
The idea wasn’t new (GridBagLayout
was shipped in the very first version of Java, a decade before that) but powerful: as soon as you have a grid structure, you can define panes as a combinaison of cells group, spanning horizontally or vertically. But, beyond what the flex
layout can do, it is not an exclusive “or”: panes can span on cells in both dimensions at the same time.
Use case
Let’s describe a typical use case with:
- a header with a fixed height, that will contain switches to show or hide the side panels;
- a contents area;
- a left pane to, say, display “settings”;
- a right pane to, say, display “comments”.
In CSS terms, this grid has two rows:
- one full-width row, spanning 3 columns for a
header
pane, and 48px high; - another row with the sequence of
settings
,contents
andcomments
cells occupying the remaining available vertical space (1fr
). Horizontally, the first and last cells should occupy only the width required by their contents (min-content
), the contents one occupying the remaining space:
#parent-pane {
display: grid;
grid-template:
"header header header" 48px
"settings contents comments" 1fr
/ "min-content" 1fr "min-content";
}
}
Then all you have to do is to assign those grid area names to your HTML pane elements:
#settings {
grid-area: settings;
min-width: min-content;
}
#contents {
grid-area: contents;
}
#comments {
grid-area: comments;
min-width: min-content;
}
Quite easy, isn’t it? Now, let’s say that we want to make the panes resizable.
Resizing
The usual UI for resizing is a gutter between resizable panels which, when hovered, displays an horizontal or vertical resizing icon.
Unfortunately the implied UX is not natively supported by browsers, and to implement it you need to add JavaScript code to manually listen to pointer events (down, moving and up) then programmatically set new sizes.
Alternatively, the standard way to resize a block does not any require JS, but one a line of CSS:
#settings, #comments {
resize: horizontal; // Or vertical, or both
}
When doing so, a new icon appear at the bottom-right corner of the block. It will be the same, whatever the resize axis your specified:
Another side benefit of using it is that it will automatically remembers it new size once you modify it. Also, as a CSS feature, it implicitly propagates that size change to the parent layout. In the case of our grid example, it means that a min-content
or a 1fr
can be implicitly changed to some greater of smaller pixels size, when the resizing occurs.
Resizing backwards
Now appears an issue we may not have foreseen: if we can only resize from the bottom-right, how can we resize the left position of this pane on the right side of the screen?
The trick here is to reverse the direction of the horizontal flow: if the contents is assumed as flowing from right-to-left (like in arabic or hebrew languages), the resize UI will consistently display at the end of the flow, which becomes at left.
#comments {
direction: rtl; // right-to-left
}
Ok cool, now the resize UI appears on the left, so that we now can resize toward that direction… but all the block content (text, etc) now displays this way 😱.
To fix this, let’s distinguish the pane container from its contents:
#comments {
direction: rtl;
.comments-contents {
direction: ltr; // left-to-right
}
}
Now we have both of the benefits.
Showing and hiding
The goal here is to change the default widths of the panes (min-content
) with zero (to collapse them), depending on the switch state. To do so, let’s change them from constant to variables:
#parent-pane {
--settings-width: min-content;
--comments-width: min-content;
grid-template:
"header header header" 48px
"settings contents comments" 1fr
/ var(--settings-width) 1fr var(--comments-width);
}
}
Then, let’s add a bit of JavaScript to handle the switches clicks:
const settingsCheck = document.querySelector("#settings-check");
const commentsCheck = document.querySelector("#comments-check");
const parentPane = document.querySelector("#parent-pane");
const settingsPane = document.querySelector("#settings");
const commentsPane = document.querySelector("#comments");
settingsCheck.onchange = (e) => {
parentPane.style.setProperty(
"--settings-width",
settingsCheck.checked ? "min-content" : "0"
);
};
commentsCheck.onchange = (e) => {
parentPane.style.setProperty(
"--comments-width",
commentsCheck.checked ? "min-content" : "0"
);
};
Here is an example of the result:
Now in this example, the switch is a bit too «flip/flop»: we would like to have a smoother transition, in which the side panes collapse and expand progressively when hidden or shown. How can we do that?
Transitions
Such an improvement becomes tricky because transitions only work between discrete values. That means that we should set the exact widths of the columns: either 0
(to hide them) or any pixel value set by the user when resizing. Of course, we still want to values to never be below each panel’s min-content
.
To do so, we have to remember two things:
First, we should not try to set the width
of the #settings
, #contents
and #comments
elements themselves, because what we want to control is the whole layout, not the individual elements (actually we never any size on the elements themselves). So the transition should apply on the grid columns:
#parent-pane {
transition: grid-template-columns 0.3s;
}
Second, since we already created widths variables (--settings-width
and --comments-width
CSS properties), all we have to do is to use their values instead of the min-content
“constant”:
let settingsWidth;
let commentsWidth;
settingsCheck.onchange = (e) => {
parentPane.style.setProperty(
"--settings-width",
settingsCheck.checked ? settingsWidth + "px" : "0"
);
};
commentsCheck.onchange = (e) => {
parentPane.style.setProperty(
"--comments-width",
commentsCheck.checked ? commentsWidth + "px" : "0"
);
};
But where do the values of those settingsWidth
and commentsWidth
JS variables come from? We read them by observing the resizing of the panes:
const settingsObserver = new ResizeObserver((entries) => {
settingsWidth = entries[0].borderBoxSize[0].inlineSize;
parentPane.style.setProperty("--settings-width", settingsWidth + "px");
});
settingsObserver.observe(settingsPane);
const commentsObserver = new ResizeObserver((entries) => {
commentsWidth = entries[0].borderBoxSize[0].inlineSize;
parentPane.style.setProperty("--comments-width", commentsWidth + "px");
});
commentsObserver.observe(commentsPane);
Now that should work… aside some glitches: because reading those values and setting them on the CSS variables triggers unwanted immediate UI updates. To make sure to have transitions visually occur only when hitting the switches, let’s disable the transition in the interim:
settingsCheck.onchange = (e) => {
parentPane.style.setProperty("--transition", "0.3s");
parentPane.style.setProperty(
"--settings-width",
commentsCheck.checked ? settingsWidth + "px" : "0"
);
};
commentsCheck.onchange = (e) => {
parentPane.style.setProperty("--transition", "0.3s");
parentPane.style.setProperty(
"--comments-width",
commentsCheck.checked ? commentsWidth + "px" : "0"
);
};
const settingsObserver = new ResizeObserver((entries) => {
settingsWidth = entries[0].borderBoxSize[0].inlineSize;
parentPane.style.setProperty("--transition", "0");
parentPane.style.setProperty("--settings-width", settingsWidth + "px");
});
const commentsObserver = new ResizeObserver((entries) => {
commentsWidth = entries[0].borderBoxSize[0].inlineSize;
parentPane.style.setProperty("--transition", "0");
parentPane.style.setProperty("--comments-width", commentsWidth + "px");
});
Now everything is working fine. Here is an example of doing it: