Building Split-Pane Layouts and Resizable Panels with Tailwind CSS
AI generated
tw
Tailwind CSS · UI Pattern · Split Pane
Split-Pane Layouts and Resizable Panels
Adjusting grid-template-columns dynamically, drag handle and minimum widths with Tailwind

A split-pane layout with a mouse-draggable divider between two areas has become inseparable from modern code editors such as VS Code and is increasingly showing up in web applications with a file tree, a preview pane or comparison views. The challenge lies less in the obvious part, dragging the divider, and more in the details: dynamic grid column widths that have to be set through JavaScript rather than CSS alone, minimum width constraints so no panel disappears entirely, and cursor feedback that stays consistent throughout the drag. This article walks through the full setup of a split-pane layout with Tailwind CSS using the example of a code editor with a file tree on the left and an editor area on the right.

15 min read Split Pane Resizable Panels

1. What makes a split-pane layout technically distinct

A split-pane layout differs from most other responsive layouts in that the width split between panels depends not only on screen size but on a direct user interaction. While an ordinary responsive grid determines its column widths through fixed breakpoints and utility classes, a split pane has to store the exact column width as a continuous value, freely chosen through dragging, and reapply it on every frame during the drag. That goes beyond what plain CSS classes can express, which is exactly why JavaScript necessarily enters the picture at this one spot, while the rest of the layout still runs entirely on Tailwind utilities.

The usual technical foundation is CSS Grid with a grid-template-columns declaration made of two fixed or relative values and a narrow column in between for the drag handle itself. Tailwind provides the static grid utilities like grid grid-cols-[...], and the dynamic portion, meaning the actual width of the first column, gets set through an inline style or a CSS custom property during the drag interaction. This combination of a static Tailwind skeleton plus a single dynamic value is a pattern that recurs across almost every interactive but otherwise structurally simple component.

2. Adjusting grid-template-columns dynamically via JavaScript

The central mechanism of a split-pane layout is a mousedown action on the drag handle that registers a mousemove listener on the document and, on every mouse movement, computes the new column width, usually as the difference between the current mouse position and the left edge of the grid container. That computed width then gets set as a CSS custom property on the grid container, for instance --left-width, which in turn gets referenced inside the grid-template-columns declaration. The advantage of this custom property indirection over directly setting grid-template-columns is that Tailwind's arbitrary value syntax can reference the custom property once, and afterward only the property itself needs updating through JavaScript.

For performance, it matters not to run the width calculation unthrottled on every single mousemove event, but to throttle it through requestAnimationFrame, so very fast mouse movements do not trigger unnecessary, overlapping layout recalculations. The mouseup listener on the document ends the drag interaction and removes the mousemove listener again, since a permanently active listener would otherwise waste resources even when no drag interaction is actually taking place.


<div
  x-data="{
    leftWidth: 280,
    dragging: false,
    startDrag(e) {
      this.dragging = true;
      const startX = e.clientX;
      const startWidth = this.leftWidth;
      const onMove = (ev) => {
        const delta = ev.clientX - startX;
        this.leftWidth = Math.min(480, Math.max(180, startWidth + delta));
      };
      const onUp = () => {
        this.dragging = false;
        document.removeEventListener('mousemove', onMove);
        document.removeEventListener('mouseup', onUp);
      };
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
    }
  }"
  x-bind:style="`grid-template-columns: ${leftWidth}px 4px 1fr`"
  class="grid h-screen">

  <!-- File tree -->
  <aside class="overflow-y-auto bg-slate-50 dark:bg-slate-900">File tree</aside>

  <!-- Drag handle -->
  <div
    x-on:mousedown="startDrag($event)"
    x-bind:class="dragging ? 'bg-sky-500' : 'bg-slate-200 hover:bg-sky-400'"
    class="cursor-col-resize transition-colors"></div>

  <!-- Editor -->
  <main class="overflow-auto">Editor area</main>
</div>

3. Drag handle styling between the two panels

The drag handle itself should stay narrow and understated at rest, typically four pixels wide with a neutral base color, since it should barely draw attention during normal use. On hover, the clickable area gets visually highlighted with a bolder color, signaling to the user that an interaction is possible here before they even start clicking. To make the actual hit area more generous than the visible four pixels, an invisible, wider hover zone of roughly 8 to 12 pixels is recommended, realized through extra padding or a pseudo-element, without changing the actual visible width of the divider line.

During an active drag interaction, the handle should take on an even bolder color than the plain hover state, so it stays unambiguously clear even during fast mouse movement that a drag is genuinely happening rather than just a hover. It is also worth adding a small, vertically centered grip indicator inside the handle, for instance three small dots or lines, which visually marks the handle's location on taller panels, especially when the divider itself spans the full screen height and could otherwise be easy to miss.

4. Minimum width constraints for both panels

Without explicit minimum width constraints, a user could theoretically shrink a panel down to zero pixels, rendering the content useless and, in the worst case, making the divider itself impossible to grab because it disappears past the edge of the container. In the code example above, this is solved with Math.min(480, Math.max(180, ...)), which clamps the computed width to a fixed range between 180 and 480 pixels before it is even set as the new value, rather than enforcing the limit after the fact via CSS min-width.

Enforcing this limit directly in the JavaScript calculation instead of relying on CSS alone matters, because a purely CSS-based min-width constraint would correctly clamp the visual result, but the component's internal state could still hold a value outside the allowed range, which would cause incorrect values to get persisted later, for instance in local storage. The minimum width for the right panel is ensured indirectly through the 1fr unit in the grid definition, which automatically claims all remaining space; a min-width: 0 declaration on the right panel may additionally be needed to avoid overflow issues with very wide content.

5. Cursor feedback throughout the drag interaction

The cursor over the drag handle should consistently show cursor-col-resize, signaling to the user before they even click that a horizontal resize is possible here. Once a drag interaction is active, that cursor style has to persist even when the mouse briefly moves outside the narrow handle area during the drag, for instance over the file tree or the editor content, since the browser would otherwise incorrectly switch to the regular text cursor or arrow mid-interaction.

This is usually solved by applying a class such as cursor-col-resize select-none to the <body> element during the active drag, forcing the cursor globally across the whole page while also disabling text selection at the same time. Without disabling text selection, any fast mouse movement during the drag would accidentally highlight text inside the file tree or editor, making the interaction immediately unpleasant and error-prone. Both classes get removed from the <body> again in the mouseup handler once the drag interaction ends.

6. Use case: code editor with a file tree and editor area

In the concrete use case of a code editor, the left panel gets the file tree with its own vertical scroll behavior through overflow-y-auto, while the right panel holds the actual editor content, which usually needs its own tabs above the editor area once multiple files are open simultaneously. Both panels share the same full screen height, typically through h-screen on the surrounding grid container, and each panel has to be able to scroll independently of the other, so a scroll action inside the file tree does not affect the editor area or vice versa.

With more complex editor interfaces, a third panel is commonly added, for instance a terminal or console area below the editor, which expands the layout from a simple two-column grid into a nested grid with both a horizontal and a vertical split handle. The mechanism presented here, using a custom property and a JavaScript drag handler, can be reused directly for that case, the grid definition simply extends from grid-template-columns to an additional nested grid-template-rows declaration for the lower area.

7. Responsive behavior on small screens

A mouse-draggable split-pane layout makes little sense on a touchscreen with limited screen width, since there simply is not enough horizontal room for two side-by-side panels, and a 4-pixel drag handle would be nearly impossible to hit precisely on a touch device anyway. Below a defined breakpoint, usually md, the layout should therefore switch entirely to a different pattern, for instance an expandable file tree rendered as an overlay or bottom sheet, while the editor area claims the full screen width for itself.

This switch can be implemented with Tailwind using two parallel layout variants that toggle responsively between split pane and mobile variant with hidden md:grid and md:hidden respectively, instead of trying to stretch the same grid-based layout with extra conditional logic to fit both cases. The JavaScript drag state does not even need to exist in the mobile variant, since no drag handle gets rendered there at all, which keeps the component leaner overall, rather than maintaining one single implementation covering every case with conditionally disabled dragging.

8. Persisting the panel width in local storage

Users who have once adjusted the panel width to a size that suits them generally expect that setting to survive a page reload, rather than snapping back to the default width on every visit. The simplest solution is to write the current width value to the browser's local storage on every mouseup event, meaning at the end of every drag interaction, rather than on every single mousemove event, since writing to local storage frequently during a drag would cause unnecessary performance overhead.

On initial load of the component, the stored value gets read from local storage and used as the starting value for the panel width, applying the exact same minimum and maximum checks used during the drag interaction itself. A stored value that has become invalid, for instance because the allowed range changed later, would otherwise produce an unusable layout that could only be corrected through a fresh user interaction.

9. Accessibility: keyboard operation of the drag handle

A drag handle that only responds to the mouse entirely excludes keyboard users and screen reader users from resizing the panels, which is why the handle should be marked up semantically with role="separator" and aria-orientation="vertical", combined with tabindex="0" so it becomes focusable via keyboard at all. Once the handle is focused, the left and right arrow keys should adjust the panel width in fixed steps of roughly 20 to 40 pixels, so the component remains fully operable without a mouse.

In addition to the arrow keys, a correctly implemented role="separator" also needs the attributes aria-valuenow, aria-valuemin and aria-valuemax, which expose the current width value and the allowed range to screen readers, similar to a slider element. The handle's focus state should be at least as visually clear as its hover state, usually through an additional Tailwind focus ring, since keyboard users would otherwise have no way to tell whether the handle is focused at all and thus ready for arrow-key control.

Property Typical value Purpose Where set
Left minimum width 180px Prevents an unusably narrow file tree JavaScript clamp during drag
Left maximum width 480px Prevents the editor area from becoming too narrow JavaScript clamp during drag
Visible handle width 4px Subtle divider at rest Tailwind utility class
Handle hit area 8-12px More generous click area than the visible width Padding or pseudo-element
Keyboard step size 20-40px Arrow key control without a mouse Keydown handler on the separator

Mironsoft

Tailwind CSS architecture, design systems, and performance

Tailwind frontends that stay maintainable despite thousands of utility classes?

We review existing Tailwind projects for bloated class lists, inconsistent design tokens, and unused CSS remnants, then build a design system that scales cleanly instead of getting messier with every component.

Design System Review

Checking tokens, spacing scale, and component consistency for maintainability.

Performance Optimization

Systematically reducing CSS bundle size, purge configuration, and load times.

Component Architecture

Building reusable, well-structured components instead of sprawling class lists.

10. Summary

Split-Pane Layouts and Resizable Panels with Tailwind: The Essentials at a Glance

Grid foundation

Static Tailwind grid with a column width set dynamically through a CSS custom property.

Constraints

Clamp minimum and maximum width directly in the JavaScript calculation, not just after the fact via CSS.

Cursor & selection

Set cursor-col-resize and select-none globally on the body while a drag interaction is active.

Accessibility

role=separator with arrow key control and aria-valuenow as a full alternative to the mouse.

11. FAQ: Split-Pane Layouts and Resizable Panels with Tailwind: The Essentials at a Glance

1Why isn't plain CSS enough for a split-pane layout?
Because the column width is a continuous value freely chosen through dragging, which has to be recalculated on every mouse movement. CSS classes only cover fixed, predefined width steps, not freely chosen in-between values.
2Why use a CSS custom property instead of directly setting grid-template-columns?
Both approaches work technically, but a custom property keeps the static grid definition in Tailwind classes and cleanly separates the one dynamic value from the rest of the declaration.
3Should the width calculation run on every single mousemove event?
Best throttled through requestAnimationFrame, so very fast mouse movements do not trigger unnecessary, overlapping layout recalculations and the interaction stays smooth.
4Why does select-none need to be set on the body during a drag?
Without disabling selection, any fast mouse movement during the drag would accidentally highlight text in the surrounding panels, making the interaction immediately error-prone.
5How do you prevent a panel from shrinking all the way to zero pixels?
Through a Math.min/Math.max clamp directly in the JavaScript calculation, before the new value even gets set, rather than only enforcing it afterward via CSS min-width.
6How does a split-pane layout work on touch devices?
Generally not as a classic drag layout, since there is not enough horizontal room for two panels. Below a breakpoint, an overlay or bottom sheet is usually used instead.
7How is the panel width persisted across a page reload?
The value gets written to the browser's local storage on every mouseup event and read back in on the next load, applying the same minimum and maximum checks used during dragging.
8How is a split-pane layout made operable for keyboard users?
The handle gets role=separator, tabindex=0, and responds to the left and right arrow keys with fixed width steps, complemented by aria-valuenow, aria-valuemin and aria-valuemax.
9Can the same mechanism be used for a third, lower panel too?
Yes, the mechanism using a custom property and a JavaScript drag handler can be reused directly, the grid definition simply gains an additional grid-template-rows declaration.
10Why should the visible handle width be smaller than the clickable area?
A narrow, understated divider line looks visually tidier, while a click area that is too narrow makes hitting the handle unnecessarily difficult. Extra padding resolves that tradeoff.