Building a Resizable Grid
It starts with a layout. It always starts with a layout. "I want an IDE," I tell myself. "I want something that feels like Sublime Text. I want a sidebar. I want a terminal at the bottom. I want a main editor area. And I want to resize them all." I want to build rich user interfaces that feel like *applications*, not websites.
So I look at the tools. react-resizable-panels is great, but it's 1-dimensional. To get a grid, you have to nest panels inside panels. You have a Row, and inside that Row, you have two Columns. And inside one of those Columns, you have another Row. It's a tree. But I don't want a tree. I want a grid. And then I try to use it on my tablet. I try to grab that 6px wide border with my thumb. I fail. I try again. I fail. Why is this so hard?
I look at react-grid-layout. It's powerful. But it feels like a dashboard. It wants me to drag widgets around. It wants to manage "items." I just want to move a line. I just want to say "this column is now wider."
I realized I was fighting the abstractions. I was trying to force a tree structure to behave like a grid, or a dashboard engine to behave like a layout engine.
The Epiphany: CSS Grid is Already the Answer
I stopped looking at React libraries and looked at CSS.
grid-template-columns: 200px 1fr 300px;grid-template-rows: 1fr 200px;
That's it. That's the API I want. CSS Grid already solves the layout problem perfectly. It handles the alignment, the spacing, the responsiveness. All I need is a way to change those numbers when I drag a mouse.
And what is a drag? It's not state. It's not a value. It's a stream. It's pointerdown. Then a flurry of pointermove events. Then pointerup. It's a sequence of events over time. And when you say "sequence of events over time," there is only one tool that should come to mind. RxJS.
How it Works: The Architecture
I decided to build a single component. No nested providers. No recursive structures. Just one <ResizeableGrid />. But how do you resize a grid gap? You can't catch events on a gap. So I cheated. I injected columns.
// utils.ts
export let getGridTemplateColumns = (
groups: Columns,
handleSize: string = "1rem",
) => {
let lastIndex = groups.length - 1;
return groups
.map((g, index) => {
// [Content] [Handle] [Content]
return `${getLength(g.size)} ${lastIndex !== index ? handleSize : ""}`;
})
.join(" ");
};
My grid isn't just content. It's content interleaved with "handles." If I have 3 columns of content, I actually have 5 columns in the CSS Grid.
Column 1. Handle 1. Column 2. Handle 2. Column 3.
The Invisible Skeleton
There was a problem. If I defined a 3x3 grid, but only put one item in the top-left corner, the rest of the grid collapsed. CSS Grid tries to be smart. It shrinks empty tracks. But I needed the grid to be rigid. I needed it to exist even if it was empty. So I filled it with ghosts.
// grid.tsx
let areas = useMemo(() => {
return Array(columns * rows)
.fill(null)
.map((_, arrayindex) => {
// ... calculate row and column ...
return (
<div
key={gridArea}
className={`row-${row} col-${column}`}
style={{ gridArea, width: "100%", height: "100%" }}
/>
);
});
}, [columns, rows]);
I generate a div for every single cell in the grid. These divs are invisible. They have no content. But they force the grid to respect the grid-template-rows and grid-template-columns I defined. They hold the structure open so the resize handles have somewhere to live.
"But wait," I hear you ask. "Doesn't that create a lot of DOM nodes?" Yes, it does. But for an application layout, we are talking about maybe 12 to 20 divs max. We aren't rendering a data grid with 10,000 rows. We are rendering the skeleton of the page. The cost is negligible compared to the stability it buys us.
The "Fat Finger" Problem
Speaking of handles, have you ever tried to grab a 6px border on a touch screen? It's miserable. I wanted a thin, elegant line. But I wanted a big, chunky hit area. CSS to the rescue again.
// grid.css.ts
export let dragHandelButton = style({
// ...
position: "absolute",
width: "100%",
height: "100%",
// ...
});
export let dragHandelButtonVariant = styleVariants({
vertical: [
dragHandelButton,
{
cursor: "ew-resize",
paddingLeft: "0.5rem",
paddingRight: "0.5rem",
left: "-0.5rem", // Center the padding over the line
},
],
// ...
});
The button is transparent. It has 0.5rem padding on both sides. It's positioned negatively so that the padding centers exactly over the visible line. To the eye, it's a pixel. To the finger, it's a highway.
Accessibility: Not Just for Mouse Users
But what if you can't use a mouse? What if you can't use a touchscreen? A resize handle is an interactive element. It must be focusable. It must be operable with a keyboard. I made each handle a <button>.
// dragHandle.tsx
<button
{...props}
tabIndex={0}
onKeyDown={handleKeyDown}
// ...
/>
And I implemented the arrow keys.
// dragHandle.tsx
let handleKeyDown: KeyboardEventHandler<HTMLElement> = (event) => {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) {
return;
}
let movementX = event.key === "ArrowLeft" ? -8 : event.key === "ArrowRight" ? 8 : 0;
// ...
onResize({ movementX, ... });
};
If you tab to a vertical handle, you can use Left/Right arrows to move it by 8px. If you tab to a horizontal handle, you can use Up/Down. It uses the exact same onResize stream as the mouse interaction.
This isn't just "nice to have." It's essential. If your tool is for developers, you have to assume some of them are power users who hate leaving the keyboard.
The Missing Piece: useDrag
I keep mentioning "the stream," but where does it come from? I built a hook called useDrag. Its job is to normalize the chaos of browser events. We use Pointer Events. They are the modern standard that unifies mouse, touch, and pen interactions into a single API. useDrag abstracts this away.
// useDrag.ts (Conceptual)
export function useDrag() {
// ...
// Listen for pointer events
let move$ = fromEvent(document, "pointermove");
// Calculate deltas
return move$.pipe(
map(event => ({
movementX: event.movementX,
// ...
}))
);
}
It returns a drag$ observable that emits clean, normalized { movementX, movementY } objects. The grid component doesn't care if you are using a mouse, a trackpad, or an Apple Pencil. It just consumes the delta.
The Unit Problem: Pixels vs. Fractions
One of the trickiest parts of building a grid is units. CSS Grid loves fr (fraction) units. 1fr 2fr means the second column is twice as wide as the first. But pointer events are in pixels. You drag 10px to the right. If you try to mix them, you go insane. "Add 10px to 1fr." What does that even mean?
So I normalize everything. When a drag starts, I read the current computed pixel width of the columns. I do all the math in pixels. 200px + 10px = 210px. But then, before I save the state, I convert it back to relative units.
// utils.ts
export function toPercent(sizePx: number, parent: HTMLElement, orientation: "vertical" | "horizontal") {
let parentSize = orientation === "vertical" ? parent.clientWidth : parent.clientHeight;
return `${(sizePx / parentSize) * 100}fr`;
};
Why fr and not %? Because fr handles the "available space" better when you have fixed-size tracks mixed in. By converting the pixel result into a fraction of the container, the grid remains responsive. If you resize the window, the columns scale proportionally.
Collapsing Panels
"But what if I want to hide the sidebar?" In an IDE, you often want to collapse a panel completely. My grid supports this natively via constraints.
// grid.stories.tsx
defaultValue={{
columns: [
{ size: 200, minSize: 0, maxSize: 500 }, // Sidebar
"1fr" // Main content
]
}}
If you drag the sidebar to the left, it shrinks. If you hit 0px, it vanishes. Because we are using CSS Grid, 0fr or 0px effectively hides the column. The content inside is still there (so state is preserved), but it takes up no space.
Persistence: Remembering the State
There is nothing more annoying than an IDE that forgets your layout when you refresh the page. Because I'm using RxJS, I can tap into the state stream easily.
// grid.tsx
let [gridState, setGridState] = useGridState({
defaultColumns: ["auto", "auto", "auto"],
defaultRows: ["auto", "auto"],
persist: "my-grid-layout" // Auto-save to localStorage
});
I built a custom hook useGridState that wraps the RxJS behavior subject. It has built-in persistence. When the gridState updates (which happens on mouseup), it automatically writes to localStorage. When the component mounts, it rehydrates from storage. It's one line of code, but it makes the grid feel "pro."
The API: Controlled vs Uncontrolled
One of the biggest challenges in building reusable components is deciding who owns the state. Should the component manage itself? Or should the parent control it? I decided to follow the Web Platform.
Just like a native <input>, the grid supports both modes. You can let it manage itself (Uncontrolled) or you can take the wheel (Controlled). It feels familiar because it mirrors the API you've been using since HTML 1.0.
Uncontrolled Mode is great for simple layouts where you just want it to work. You give it a defaultValue, and it handles the rest. This follows the same philosophy as our Building Custom React Inputs approach: let the component manage its own state when possible.
<ResizeableGrid
persist="my-layout"
defaultValue={{
columns: [200, "1fr", { size: 200, minSize: 100, maxSize: 300 }],
rows: ["auto", "auto"],
}}
>
<GridArea column={{ start: 1, end: 2 }} row={{ start: 1, end: 2 }}>
Sidebar
</GridArea>
{/* ... */}
</ResizeableGrid>
Controlled Mode is for when you need full power. Maybe you want a button that toggles the sidebar. Maybe you want to save the layout to a database instead of localStorage.
let [state, setState] = useGridState({
columns: 3,
rows: 2,
defaultColumns: [{ size: 200, mode: "static" }, "1fr", "auto"],
defaultRows: ["auto", 200],
});
let toggleSidebar = () => {
setState(current => {
// ... logic to toggle column size ...
})
}
return (
<>
<button onClick={toggleSidebar}>Toggle Sidebar</button>
<ResizeableGrid
value={state}
onChange={setState}
>
{/* ... */}
</ResizeableGrid>
</>
);
By exposing both value/onChange and defaultValue, we get the flexibility of a controlled component with the ease of use of an uncontrolled one.
Is RxJS Overkill?
I know what you're thinking. "You imported the entire RxJS library just to drag a div?" First, RxJS is tree-shakeable. I'm only importing merge, map, share, tap, and sample. Second, try writing this in vanilla JS.
You need pointerdown handlers. You need pointermove handlers attached to document (so you don't lose the drag if the mouse moves fast). You need keydown handlers for accessibility. You need to clean them all up when the component unmounts. You need to throttle the updates to 60fps.
With RxJS, I just say: merge(pointer$, key$).pipe(...) It's declarative. It handles the cleanup. It handles the race conditions. It's not overkill; it's the right tool for the job.
The Stream: RxJS in Action
Now for the engine. I didn't want to manage isDragging state in React. I didn't want to trigger re-renders 60 times a second just to update a width. I wanted to bypass React entirely during the drag.
// grid.tsx
useEffect(() => {
// ...
let dragEvent$ = merge(drag$, resize$).pipe(share());
let xSubscription = dragEvent$.pipe(onDrag("col")).subscribe((columns) => {
setColumns(columns);
});
let ySubscription = dragEvent$.pipe(onDrag("row")).subscribe((rows) => {
setRows(rows);
});
return () => {
xSubscription.unsubscribe();
ySubscription.unsubscribe();
};
}, ...);
Wait, setColumns? That triggers a re-render! Yes, but look at onDrag. Inside the onDrag operator pipeline, I do something unconventional. I talk directly to the DOM.
// grid.tsx
tap((next) => {
if (type === "col") {
parent.style.gridTemplateColumns = getGridTemplateColumns(
next,
dragHandleSize,
);
}
// ...
}),
sample(dragEnd$), // Only emit to React when the drag stops!
I update the style property of the grid container directly in the stream. The browser repaints instantly. React knows nothing about it.
Only when the user lifts their mouse (sample(dragEnd$)) do I let the value pass through to setColumns. React wakes up, sees the new state, and reconciles. But by then, the heavy lifting is done.
The Safety Valve
"But isn't direct DOM manipulation dangerous?" It can be. If you modify the DOM and then React tries to modify it, you get conflicts. But here, React owns the structure (the divs), and I only touch the style of the container.
Also, notice the cleanup in useEffect. When the component unmounts, xSubscription.unsubscribe() is called. This kills the stream. Even if a drag event is pending, it won't fire. We are safe.
The Logic: Push and Pull
Resizing a grid is a zero-sum game. If I make the sidebar wider, the main content must get narrower. I can't just change one number. I have to steal from Han to pay Chewie.
// utils.ts
function applyConstrained(...) {
let total = currentEntry.size + nextEntry.size;
// Clamp current
currentEntry.size = clamp(currentEntry.size, current.min, current.max);
// Adjust next
nextEntry.size = total - currentEntry.size;
// Clamp next
nextEntry.size = clamp(nextEntry.size, next.min, next.max);
// Adjust current again (in case next hit a constraint)
currentEntry.size = total - nextEntry.size;
}
It's a dance. I push right. The current column grows. The next column shrinks. But wait! The next column has a minSize of 200px. It refuses to shrink further. So I have to stop growing the current column.
This logic happens in the stream, on every frame. It feels solid. It feels physical.
Conclusion
I could have used a library. I could have accepted the nested divs. I could have lived with the bad touch support. But sometimes, you just want the thing to feel right.
- I wanted a grid. So I used CSS Grid.
- I wanted a stream of events. So I used RxJS.
- I wanted it to be fast. So I bypassed React for the hot path.
It’s not a generic "Dashboard Builder." It’s not a "Panel Manager." It’s just a grid. And it resizes.
This is just the beginning. With this foundation, we can build anything. Collapsible sidebars, tabbed interfaces, complex data grids. The primitives are there. The performance is there. The only limit is the layout you can imagine.