Resize Shared Partial

Don't forget the Stimulus controller!
The shared partial still requires the resize_controller.js Stimulus controller to be installed. Copy the controller from the Installation section on this page.

1. Copy shared partial files

Download all 2 shared partial files as a ZIP file.

Download all files (ZIP)

After downloading, unzip and copy the resize/ folder to your app/views/shared/components/ directory.

Copy these files to your app/views/shared/components/resize/ directory:

2. Usage example

<%# ── 1. Docs layout with a collapsible sidebar. The toggle button is wired up automatically (aria-controls, aria-expanded). %>
<% docs_sidebar = capture do %>
  <div class="min-w-40 p-3">
    <p class="px-2 pb-2 pt-1 text-[11px] font-medium text-neutral-400 dark:text-neutral-500">Getting started</p>
    <ul class="space-y-px text-sm">
      <% ["Introduction", "Installation", "Configuration", "Deploying"].each_with_index do |page, index| %>
        <li>
          <a href="#" <%= "aria-current=page" if index.zero? %>
             class="block truncate rounded-md px-2 py-1.5 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200 <%= index.zero? ? "bg-black/5 font-medium text-neutral-900 dark:bg-white/10 dark:text-white" : "text-neutral-600 hover:bg-black/5 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white" %>"><%= page %></a>
        </li>
      <% end %>
    </ul>
  </div>
<% end %>
<% docs_main = capture do %>
  <div class="flex items-center gap-2 border-b border-black/5 px-3 py-2 dark:border-white/5">
    <button type="button" data-action="resize#toggle" aria-label="Toggle sidebar"
            class="flex size-7 shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:text-neutral-400 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200">
      <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.75" width="12.5" height="10.5" rx="2"/><path d="M6 2.75v10.5"/></svg>
    </button>
    <span class="truncate text-sm font-medium text-neutral-900 dark:text-white">Introduction</span>
  </div>
  <div class="space-y-3 p-5 text-sm leading-6 text-neutral-600 dark:text-neutral-400">
    <p>Harbor is a deployment toolkit for Rails apps. Point it at a server, and it builds, ships and restarts your app with zero downtime.</p>
    <p>Drag the sidebar past half of its minimum width and it snaps shut. Press Enter on the handle or use the button to bring it back.</p>
  </div>
<% end %>
<%= render "shared/components/resize/resize",
  classes: "h-80 w-[40rem] max-w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900",
  panels: [
    { tag: :nav, default_size: 30, min_size: "160px", max_size: 45, collapsible: true,
      handle_label: "Resize sidebar", classes: "bg-neutral-50 dark:bg-neutral-950/40",
      html: { aria: { label: "Documentation" } }, content: docs_sidebar },
    { tag: :main, min_size: 40, content: docs_main }
  ] %>

<%# ── 2. Query editor: a vertical group with a grip handle. %>
<% query = capture do %>
  <p class="shrink-0 border-b border-black/5 px-4 py-2 text-xs font-medium text-neutral-500 dark:border-white/5 dark:text-neutral-400">Query</p>
  <pre class="min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-xs leading-5 text-neutral-700 dark:text-neutral-300">SELECT plan, COUNT(*) AS customers, SUM(mrr) AS mrr
FROM subscriptions
WHERE status = 'active'
GROUP BY plan ORDER BY mrr DESC;</pre>
<% end %>
<% results = capture do %>
  <p class="shrink-0 px-4 pb-1 pt-3 text-xs font-medium text-neutral-500 dark:text-neutral-400">Results</p>
  <div class="min-h-0 flex-1 overflow-auto px-4 pb-3">
    <table class="w-full text-left text-xs">
      <thead class="text-neutral-500 dark:text-neutral-400">
        <tr><th class="py-1.5 font-medium">plan</th><th class="py-1.5 text-right font-medium">customers</th><th class="py-1.5 text-right font-medium">mrr</th></tr>
      </thead>
      <tbody class="divide-y divide-black/5 tabular-nums text-neutral-800 dark:divide-white/5 dark:text-neutral-200">
        <% [["Team", "412", "$40,376"], ["Business", "96", "$28,704"], ["Pro", "1,284", "$24,396"]].each do |plan, customers, mrr| %>
          <tr><td class="py-1.5"><%= plan %></td><td class="py-1.5 text-right"><%= customers %></td><td class="py-1.5 text-right"><%= mrr %></td></tr>
        <% end %>
      </tbody>
    </table>
  </div>
<% end %>
<%= render "shared/components/resize/resize",
  orientation: :vertical,
  handle: :grip,
  classes: "h-80 w-[40rem] max-w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900",
  panels: [
    { default_size: 50, min_size: 25, handle_label: "Resize query and results", classes: "flex flex-col", content: query },
    { min_size: 25, classes: "flex flex-col bg-neutral-50 dark:bg-neutral-950/40", content: results }
  ] %>

<%# ── 3. Mail app saved in a cookie: Rails renders the saved sizes, so nothing jumps on reload.
       The sidebar collapses to a 52px icon rail; labels stay readable to screen readers (sr-only). %>
<% mailboxes = capture do %>
  <ul class="space-y-px p-2 text-sm">
    <% [["Inbox", "M1.75 9.25 3.6 3.6a1.25 1.25 0 0 1 1.2-.85h6.4a1.25 1.25 0 0 1 1.2.85l1.85 5.65v2.75a1.25 1.25 0 0 1-1.25 1.25H3A1.25 1.25 0 0 1 1.75 12V9.25ZM1.75 9.25H5.5l.75 1.5h3.5l.75-1.5h3.75"],
        ["Starred", "M8 1.75 9.9 5.9l4.35.5-3.2 3.05.85 4.3L8 11.6l-3.9 2.15.85-4.3-3.2-3.05 4.35-.5L8 1.75Z"],
        ["Sent", "m14.25 1.75-6.5 6.5M14.25 1.75l-4.25 12.5-2.25-6-6-2.25 12.5-4.25Z"]].each_with_index do |(name, icon), index| %>
      <li>
        <a href="#" title="<%= name %>" <%= "aria-current=page" if index.zero? %>
           class="flex items-center gap-2.5 rounded-md px-2 py-1.5 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 group-data-[collapsed]/sidebar:justify-center dark:focus-visible:outline-neutral-200 <%= index.zero? ? "bg-black/5 font-medium text-neutral-900 dark:bg-white/10 dark:text-white" : "text-neutral-600 hover:bg-black/5 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white" %>">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="<%= icon %>"/></svg>
          <span class="truncate group-data-[collapsed]/sidebar:sr-only"><%= name %></span>
        </a>
      </li>
    <% end %>
  </ul>
<% end %>
<% messages = capture do %>
  <ul class="h-full divide-y divide-black/5 overflow-y-auto dark:divide-white/5">
    <% [["Maya Chen", "Q4 roadmap review", "9:41"], ["Stripe", "Your payout is on its way", "8:15"], ["Jonas Weber", "Re: Onboarding copy", "Tue"]].each_with_index do |(from, subject, time), index| %>
      <li>
        <a href="#" class="block px-4 py-3 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200 <%= index.zero? ? "bg-neutral-50 dark:bg-white/5" : "hover:bg-neutral-50 dark:hover:bg-white/5" %>">
          <span class="flex items-baseline justify-between gap-2"><span class="truncate text-sm font-medium text-neutral-900 dark:text-white"><%= from %></span><span class="shrink-0 text-xs tabular-nums text-neutral-400 dark:text-neutral-500"><%= time %></span></span>
          <span class="block truncate text-sm text-neutral-600 dark:text-neutral-400"><%= subject %></span>
        </a>
      </li>
    <% end %>
  </ul>
<% end %>
<% reading_pane = capture do %>
  <div class="h-full overflow-y-auto p-5">
    <h3 class="text-base font-semibold text-neutral-900 dark:text-white">Q4 roadmap review</h3>
    <p class="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">Maya Chen · Today at 9:41</p>
    <p class="mt-4 text-sm leading-6 text-neutral-600 dark:text-neutral-400">Billing ships first, then the new onboarding, and search moves to January. Could everyone leave comments by Thursday?</p>
  </div>
<% end %>
<%= render "shared/components/resize/resize",
  storage_key: "mail:layout",
  storage: :cookie,
  classes: "h-80 w-[40rem] max-w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900",
  panels: [
    { tag: :nav, default_size: 24, min_size: "150px", max_size: 32, collapsible: true, collapsed_size: "52px",
      handle_label: "Resize mailboxes", classes: "group/sidebar bg-neutral-50 dark:bg-neutral-950/40",
      html: { aria: { label: "Mailboxes" } }, content: mailboxes },
    { default_size: 36, min_size: "180px", handle_label: "Resize message list", content: messages },
    { tag: :article, min_size: 30, content: reading_pane }
  ] %>

<%# ── 4. Two cards with a floating pill handle in the gap. %>
<% this_week = capture do %>
  <div class="h-full overflow-hidden rounded-xl border border-black/10 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-neutral-900">
    <p class="text-xs font-medium text-neutral-500 dark:text-neutral-400">This week</p>
    <p class="mt-1 text-2xl font-semibold tabular-nums tracking-tight text-neutral-900 dark:text-white">$12,480</p>
    <p class="mt-1 truncate text-xs text-neutral-500 dark:text-neutral-400"><span class="font-medium text-emerald-600 dark:text-emerald-400">+8.2%</span> vs last week</p>
    <div class="mt-4 flex h-16 items-end gap-1.5" aria-hidden="true">
      <% [45, 60, 52, 78, 66, 88, 100].each_with_index do |height, day| %>
        <span class="flex-1 rounded-sm <%= day == 6 ? "bg-neutral-900 dark:bg-white" : "bg-neutral-200 dark:bg-neutral-700" %>" style="height: <%= height %>%"></span>
      <% end %>
    </div>
  </div>
<% end %>
<% top_products = capture do %>
  <div class="h-full overflow-hidden rounded-xl border border-black/10 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-neutral-900">
    <p class="text-xs font-medium text-neutral-500 dark:text-neutral-400">Top products</p>
    <ul class="mt-3 space-y-2 text-sm">
      <% [["Linen overshirt", "$3,920"], ["Canvas weekender", "$2,745"], ["Merino sweater", "$2,310"]].each do |product, revenue| %>
        <li class="flex items-center justify-between gap-3"><span class="truncate text-neutral-800 dark:text-neutral-200"><%= product %></span><span class="tabular-nums text-neutral-500 dark:text-neutral-400"><%= revenue %></span></li>
      <% end %>
    </ul>
  </div>
<% end %>
<%= render "shared/components/resize/resize",
  handle: :pill,
  classes: "h-56 w-[40rem] max-w-full",
  panels: [
    { min_size: 30, handle_label: "Resize cards", content: this_week },
    { min_size: 30, content: top_products }
  ] %>

Rendered usage example

Introduction

Harbor is a deployment toolkit for Rails apps. Point it at a server, and it builds, ships and restarts your app with zero downtime.

Drag the sidebar past half of its minimum width and it snaps shut. Press Enter on the handle or use the button to bring it back.

Query

SELECT plan, COUNT(*) AS customers, SUM(mrr) AS mrr
FROM subscriptions
WHERE status = 'active'
GROUP BY plan ORDER BY mrr DESC;

Results

plancustomersmrr
Team412$40,376
Business96$28,704
Pro1,284$24,396

Q4 roadmap review

Maya Chen · Today at 9:41

Billing ships first, then the new onboarding, and search moves to January. Could everyone leave comments by Thursday?

This week

$12,480

+8.2% vs last week

Top products

  • Linen overshirt$3,920
  • Canvas weekender$2,745
  • Merino sweater$2,310

Resize ViewComponent

Don't forget the Stimulus controller!
The ViewComponent still requires the resize_controller.js Stimulus controller to be installed. Copy the controller from the Installation section on this page.

1. Ensure the gem is installed

gem "view_component", "~> 4.2"

2. Copy component files

Download all 2 component files as a ZIP file.

Download all files (ZIP)

After downloading, unzip and copy the resize/ folder to your app/components/ directory.

Copy these files to your app/components/resize/ directory:

3. Usage example

<%# ── 1. Docs layout with a collapsible sidebar. The toggle button is wired up automatically (aria-controls, aria-expanded). %>
<%= render Resize::Component.new(classes: "h-80 w-[40rem] max-w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900") do |group| %>
  <% group.with_panel(tag: :nav, default_size: 30, min_size: "160px", max_size: 45, collapsible: true,
                      handle_label: "Resize sidebar", classes: "bg-neutral-50 dark:bg-neutral-950/40",
                      html: { aria: { label: "Documentation" } }) do %>
    <div class="min-w-40 p-3">
      <p class="px-2 pb-2 pt-1 text-[11px] font-medium text-neutral-400 dark:text-neutral-500">Getting started</p>
      <ul class="space-y-px text-sm">
        <% ["Introduction", "Installation", "Configuration", "Deploying"].each_with_index do |page, index| %>
          <li>
            <a href="#" <%= "aria-current=page" if index.zero? %>
               class="block truncate rounded-md px-2 py-1.5 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200 <%= index.zero? ? "bg-black/5 font-medium text-neutral-900 dark:bg-white/10 dark:text-white" : "text-neutral-600 hover:bg-black/5 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white" %>"><%= page %></a>
          </li>
        <% end %>
      </ul>
    </div>
  <% end %>
  <% group.with_panel(tag: :main, min_size: 40) do %>
    <div class="flex items-center gap-2 border-b border-black/5 px-3 py-2 dark:border-white/5">
      <button type="button" data-action="resize#toggle" aria-label="Toggle sidebar"
              class="flex size-7 shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:text-neutral-400 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200">
        <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.75" width="12.5" height="10.5" rx="2"/><path d="M6 2.75v10.5"/></svg>
      </button>
      <span class="truncate text-sm font-medium text-neutral-900 dark:text-white">Introduction</span>
    </div>
    <div class="space-y-3 p-5 text-sm leading-6 text-neutral-600 dark:text-neutral-400">
      <p>Harbor is a deployment toolkit for Rails apps. Point it at a server, and it builds, ships and restarts your app with zero downtime.</p>
      <p>Drag the sidebar past half of its minimum width and it snaps shut. Press Enter on the handle or use the button to bring it back.</p>
    </div>
  <% end %>
<% end %>

<%# ── 2. Query editor: a vertical group with a grip handle. %>
<%= render Resize::Component.new(orientation: :vertical, handle: :grip, classes: "h-80 w-[40rem] max-w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900") do |group| %>
  <% group.with_panel(default_size: 50, min_size: 25, handle_label: "Resize query and results", classes: "flex flex-col") do %>
    <p class="shrink-0 border-b border-black/5 px-4 py-2 text-xs font-medium text-neutral-500 dark:border-white/5 dark:text-neutral-400">Query</p>
    <pre class="min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-xs leading-5 text-neutral-700 dark:text-neutral-300">SELECT plan, COUNT(*) AS customers, SUM(mrr) AS mrr
FROM subscriptions
WHERE status = 'active'
GROUP BY plan ORDER BY mrr DESC;</pre>
  <% end %>
  <% group.with_panel(min_size: 25, classes: "flex flex-col bg-neutral-50 dark:bg-neutral-950/40") do %>
    <p class="shrink-0 px-4 pb-1 pt-3 text-xs font-medium text-neutral-500 dark:text-neutral-400">Results</p>
    <div class="min-h-0 flex-1 overflow-auto px-4 pb-3">
      <table class="w-full text-left text-xs">
        <thead class="text-neutral-500 dark:text-neutral-400">
          <tr><th class="py-1.5 font-medium">plan</th><th class="py-1.5 text-right font-medium">customers</th><th class="py-1.5 text-right font-medium">mrr</th></tr>
        </thead>
        <tbody class="divide-y divide-black/5 tabular-nums text-neutral-800 dark:divide-white/5 dark:text-neutral-200">
          <% [["Team", "412", "$40,376"], ["Business", "96", "$28,704"], ["Pro", "1,284", "$24,396"]].each do |plan, customers, mrr| %>
            <tr><td class="py-1.5"><%= plan %></td><td class="py-1.5 text-right"><%= customers %></td><td class="py-1.5 text-right"><%= mrr %></td></tr>
          <% end %>
        </tbody>
      </table>
    </div>
  <% end %>
<% end %>

<%# ── 3. Mail app saved in a cookie: Rails renders the saved sizes, so nothing jumps on reload.
       The sidebar collapses to a 52px icon rail; labels stay readable to screen readers (sr-only). %>
<%= render Resize::Component.new(storage_key: "mail:layout", storage: :cookie, classes: "h-80 w-[40rem] max-w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900") do |group| %>
  <% group.with_panel(tag: :nav, default_size: 24, min_size: "150px", max_size: 32, collapsible: true, collapsed_size: "52px",
                      handle_label: "Resize mailboxes", classes: "group/sidebar bg-neutral-50 dark:bg-neutral-950/40",
                      html: { aria: { label: "Mailboxes" } }) do %>
    <ul class="space-y-px p-2 text-sm">
      <% [["Inbox", "M1.75 9.25 3.6 3.6a1.25 1.25 0 0 1 1.2-.85h6.4a1.25 1.25 0 0 1 1.2.85l1.85 5.65v2.75a1.25 1.25 0 0 1-1.25 1.25H3A1.25 1.25 0 0 1 1.75 12V9.25ZM1.75 9.25H5.5l.75 1.5h3.5l.75-1.5h3.75"],
          ["Starred", "M8 1.75 9.9 5.9l4.35.5-3.2 3.05.85 4.3L8 11.6l-3.9 2.15.85-4.3-3.2-3.05 4.35-.5L8 1.75Z"],
          ["Sent", "m14.25 1.75-6.5 6.5M14.25 1.75l-4.25 12.5-2.25-6-6-2.25 12.5-4.25Z"]].each_with_index do |(name, icon), index| %>
        <li>
          <a href="#" title="<%= name %>" <%= "aria-current=page" if index.zero? %>
             class="flex items-center gap-2.5 rounded-md px-2 py-1.5 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 group-data-[collapsed]/sidebar:justify-center dark:focus-visible:outline-neutral-200 <%= index.zero? ? "bg-black/5 font-medium text-neutral-900 dark:bg-white/10 dark:text-white" : "text-neutral-600 hover:bg-black/5 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white" %>">
            <svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="<%= icon %>"/></svg>
            <span class="truncate group-data-[collapsed]/sidebar:sr-only"><%= name %></span>
          </a>
        </li>
      <% end %>
    </ul>
  <% end %>
  <% group.with_panel(default_size: 36, min_size: "180px", handle_label: "Resize message list") do %>
    <ul class="h-full divide-y divide-black/5 overflow-y-auto dark:divide-white/5">
      <% [["Maya Chen", "Q4 roadmap review", "9:41"], ["Stripe", "Your payout is on its way", "8:15"], ["Jonas Weber", "Re: Onboarding copy", "Tue"]].each_with_index do |(from, subject, time), index| %>
        <li>
          <a href="#" class="block px-4 py-3 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:focus-visible:outline-neutral-200 <%= index.zero? ? "bg-neutral-50 dark:bg-white/5" : "hover:bg-neutral-50 dark:hover:bg-white/5" %>">
            <span class="flex items-baseline justify-between gap-2"><span class="truncate text-sm font-medium text-neutral-900 dark:text-white"><%= from %></span><span class="shrink-0 text-xs tabular-nums text-neutral-400 dark:text-neutral-500"><%= time %></span></span>
            <span class="block truncate text-sm text-neutral-600 dark:text-neutral-400"><%= subject %></span>
          </a>
        </li>
      <% end %>
    </ul>
  <% end %>
  <% group.with_panel(tag: :article, min_size: 30) do %>
    <div class="h-full overflow-y-auto p-5">
      <h3 class="text-base font-semibold text-neutral-900 dark:text-white">Q4 roadmap review</h3>
      <p class="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">Maya Chen · Today at 9:41</p>
      <p class="mt-4 text-sm leading-6 text-neutral-600 dark:text-neutral-400">Billing ships first, then the new onboarding, and search moves to January. Could everyone leave comments by Thursday?</p>
    </div>
  <% end %>
<% end %>

<%# ── 4. Two cards with a floating pill handle in the gap. %>
<%= render Resize::Component.new(handle: :pill, classes: "h-56 w-[40rem] max-w-full") do |group| %>
  <% group.with_panel(min_size: 30, handle_label: "Resize cards") do %>
    <div class="h-full overflow-hidden rounded-xl border border-black/10 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-neutral-900">
      <p class="text-xs font-medium text-neutral-500 dark:text-neutral-400">This week</p>
      <p class="mt-1 text-2xl font-semibold tabular-nums tracking-tight text-neutral-900 dark:text-white">$12,480</p>
      <p class="mt-1 truncate text-xs text-neutral-500 dark:text-neutral-400"><span class="font-medium text-emerald-600 dark:text-emerald-400">+8.2%</span> vs last week</p>
      <div class="mt-4 flex h-16 items-end gap-1.5" aria-hidden="true">
        <% [45, 60, 52, 78, 66, 88, 100].each_with_index do |height, day| %>
          <span class="flex-1 rounded-sm <%= day == 6 ? "bg-neutral-900 dark:bg-white" : "bg-neutral-200 dark:bg-neutral-700" %>" style="height: <%= height %>%"></span>
        <% end %>
      </div>
    </div>
  <% end %>
  <% group.with_panel(min_size: 30) do %>
    <div class="h-full overflow-hidden rounded-xl border border-black/10 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-neutral-900">
      <p class="text-xs font-medium text-neutral-500 dark:text-neutral-400">Top products</p>
      <ul class="mt-3 space-y-2 text-sm">
        <% [["Linen overshirt", "$3,920"], ["Canvas weekender", "$2,745"], ["Merino sweater", "$2,310"]].each do |product, revenue| %>
          <li class="flex items-center justify-between gap-3"><span class="truncate text-neutral-800 dark:text-neutral-200"><%= product %></span><span class="tabular-nums text-neutral-500 dark:text-neutral-400"><%= revenue %></span></li>
        <% end %>
      </ul>
    </div>
  <% end %>
<% end %>

Rendered usage example

Introduction

Harbor is a deployment toolkit for Rails apps. Point it at a server, and it builds, ships and restarts your app with zero downtime.

Drag the sidebar past half of its minimum width and it snaps shut. Press Enter on the handle or use the button to bring it back.

Query

SELECT plan, COUNT(*) AS customers, SUM(mrr) AS mrr
FROM subscriptions
WHERE status = 'active'
GROUP BY plan ORDER BY mrr DESC;

Results

plancustomersmrr
Team412$40,376
Business96$28,704
Pro1,284$24,396

Q4 roadmap review

Maya Chen · Today at 9:41

Billing ships first, then the new onboarding, and search moves to January. Could everyone leave comments by Thursday?

This week

$12,480

+8.2% vs last week

Top products

  • Linen overshirt$3,920
  • Canvas weekender$2,745
  • Merino sweater$2,310

Resize Rails Components

Resizable panels with draggable handles for split views, sidebars and editor layouts. Works with mouse, touch and keyboard, supports min and max sizes, collapsible panels, nesting and persisted layouts.

Installation

1. Stimulus Controller Setup

Add the following controller to your project. It has no dependencies:

import { Controller } from "@hotwired/stimulus";

// Resizable panel groups with draggable, keyboard-accessible handles.
//
//   <div data-controller="resize" class="flex h-64">
//     <div data-resize-target="panel" data-resize-default-size="30" data-resize-min-size="20">…</div>
//     <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize sidebar"></div>
//     <div data-resize-target="panel">…</div>
//   </div>
//
// Panels and handles alternate inside one flex container. Sizes are percentages of
// the group ("30") or pixels ("240px"). Groups can be nested freely: put another
// data-controller="resize" inside a panel.
//
// Panels are pure layout boxes: keep padding and borders (along the resize axis)
// on an element inside the panel, otherwise the sizes will be slightly off.
//
// Per-panel options (data attributes on the panel):
//   data-resize-default-size     Initial size. Panels without one share what is left.
//   data-resize-min-size         Smallest size the panel can be dragged to.
//   data-resize-max-size         Largest size the panel can be dragged to.
//   data-resize-collapsible      Snap the panel closed when dragged past half of its min size.
//   data-resize-collapsed-size   Size while collapsed (default 0, e.g. "56px" for an icon rail).
//
// Actions: resize#toggle, resize#collapse, resize#expand, resize#reset, resize#set.
// Events:  resize:change (every frame while dragging), resize:commit (once per finished
//          interaction: the one to use for saving). Both carry { sizes, collapsed, panels }.
//
// Panels without an id get one, handles get aria-controls, and buttons that collapse a
// panel get aria-expanded kept in sync.

const PRECISION = 0.01;
const DOUBLE_TAP_MS = 350;
const EASING = "cubic-bezier(0.25, 1, 0.5, 1)";

const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const sum = (numbers) => numbers.reduce((total, number) => total + number, 0);

export default class extends Controller {
  static targets = ["panel", "handle", "size"];
  static values = {
    orientation: { type: String, default: "horizontal" }, // "horizontal" (side by side) or "vertical" (stacked)
    storageKey: String, // Persist the layout under this key
    storage: { type: String, default: "local" }, // "local" (localStorage) or "cookie" (readable by Rails)
    keyboardStep: { type: Number, default: 5 }, // Percent moved per arrow key press (x3 with Shift)
  };

  connect() {
    this.drag = null;
    this.frame = null;
    this.lastTap = null;
    this.transitionTimer = null;
    this.cursorHandle = null;
    this.remembered = []; // Size of each panel before it was collapsed

    // One set of delegated listeners covers every handle in the group.
    this.listeners = {
      pointerdown: this.startDrag.bind(this),
      pointermove: this.moveDrag.bind(this),
      pointerup: this.endDrag.bind(this),
      pointercancel: this.endDrag.bind(this),
      lostpointercapture: this.endDrag.bind(this),
      keydown: this.keydown.bind(this),
    };
    Object.entries(this.listeners).forEach(([type, listener]) => this.element.addEventListener(type, listener));
    // Switching windows mid-drag can swallow the pointerup, so end the drag there too.
    this.onWindowBlur = () => this.finishDrag();
    window.addEventListener("blur", this.onWindowBlur);

    this.handleTargets.forEach((handle) => {
      if (!handle.hasAttribute("role")) handle.setAttribute("role", "separator");
      if (!handle.hasAttribute("tabindex")) handle.setAttribute("tabindex", "0");
      // A separator's orientation describes the line itself, which is perpendicular to the group.
      handle.setAttribute("aria-orientation", this.horizontal ? "vertical" : "horizontal");
      handle.style.touchAction = "none"; // Let touch drags resize instead of scrolling the page
    });

    // Every panel gets an id so handles and toggle buttons can point at it with aria-controls.
    this.panelTargets.forEach((panel) => {
      if (!panel.id) panel.id = `resize-panel-${Math.random().toString(36).slice(2, 10)}`;
    });

    this.measure();
    this.warnAboutSetup();
    this.layout = null;
    this.apply(this.restore() || this.defaults());
    this.committed = this.layout;
    // A group that starts hidden (modal, tab, collapsed section) has no size yet, so
    // pixel sizes can't be resolved. The first real measurement lays it out again.
    this.initialLayout = this.available > 0 ? null : this.layout;

    this.onStorage = (event) => this.syncFromStorage(event);
    window.addEventListener("storage", this.onStorage);

    // Pixel-based constraints are converted to percentages, so they need to be
    // re-evaluated whenever the group itself changes size.
    this.resizeObserver = new ResizeObserver(() => this.refresh());
    this.resizeObserver.observe(this.element);
  }

  disconnect() {
    this.finishDrag();
    this.resizeObserver?.disconnect();
    clearTimeout(this.transitionTimer);
    this.panelTargets.forEach((panel) => (panel.style.transition = ""));
    Object.entries(this.listeners).forEach(([type, listener]) => this.element.removeEventListener(type, listener));
    window.removeEventListener("blur", this.onWindowBlur);
    window.removeEventListener("storage", this.onStorage);
  }

  // ---------------------------------------------------------------------------
  // Actions
  // ---------------------------------------------------------------------------

  // data-action="resize#toggle" data-resize-panel-param="0" (panel index, or the panel's id)
  toggle(event) {
    const index = this.panelIndex(event?.params?.panel);
    if (index < 0) return;
    this.layout.collapsed[index] ? this.expandPanel(index) : this.collapsePanel(index);
  }

  collapse(event) {
    const index = this.panelIndex(event?.params?.panel);
    if (index >= 0) this.collapsePanel(index);
  }

  expand(event) {
    const index = this.panelIndex(event?.params?.panel);
    if (index >= 0) this.expandPanel(index);
  }

  // Back to the default sizes. Also triggered by double-clicking (or double-tapping) a handle.
  reset() {
    this.measure();
    this.remembered = [];
    this.transition();
    this.apply(this.defaults());
    this.commit({ persist: false });
    this.forget();
  }

  // data-action="resize#set" data-resize-sizes-param="[30, 70]"
  // One size per panel, as percentages or pixel strings ("240px"). A collapsible panel
  // given its collapsed size (or less) is collapsed. Also callable from JS: setSizes([30, 70]).
  set(event) {
    this.setSizes(event?.params?.sizes);
  }

  setSizes(sizes) {
    if (!Array.isArray(sizes) || sizes.length !== this.panelTargets.length) {
      console.warn(`[resize] setSizes expects ${this.panelTargets.length} sizes, one per panel. Got:`, sizes);
      return;
    }

    this.measure();
    const limits = this.constraints;
    const wanted = sizes.map((size, i) => this.percent(String(size), this.layout.sizes[i]));
    const collapsed = wanted.map((size, i) => limits[i].collapsible && size <= limits[i].collapsedSize + PRECISION);

    this.transition();
    this.apply(this.fit({ sizes: wanted, collapsed }));
    this.commit();
  }

  // ---------------------------------------------------------------------------
  // Pointer dragging
  // ---------------------------------------------------------------------------

  startDrag(event) {
    const handle = this.handleFrom(event);
    if (!handle || event.button !== 0 || this.drag) return;

    // Stops text selection and the compatibility mouse events from kicking in.
    event.preventDefault();

    if (this.lastTap && this.lastTap.handle === handle && event.timeStamp - this.lastTap.time < DOUBLE_TAP_MS) {
      this.lastTap = null;
      this.reset();
      return;
    }

    this.measure();
    if (this.available <= 0) return;

    // Pointer capture keeps the drag alive when the cursor leaves the handle,
    // crosses an iframe, or leaves the window entirely.
    try {
      handle.setPointerCapture(event.pointerId);
    } catch {
      // Synthetic events (tests) have no active pointer to capture.
    }
    // Deliberately no handle.focus(): like most apps, a mouse or touch drag leaves focus
    // where it was (an editor keeps its cursor) and the handle returns to its idle look
    // on release. Keyboard users reach handles with Tab.

    this.drag = {
      handle,
      index: this.handleTargets.indexOf(handle),
      pointerId: event.pointerId,
      origin: this.coordinate(event),
      position: this.coordinate(event),
      start: this.layout, // Every move is resolved from here, so the handle never drifts from the cursor
      direction: this.horizontal && getComputedStyle(this.element).direction === "rtl" ? -1 : 1,
      moved: false,
    };

    handle.setAttribute("data-dragging", "");
    this.element.setAttribute("data-resizing", "");
    this.lockCursor();
  }

  moveDrag(event) {
    if (!this.drag || event.pointerId !== this.drag.pointerId) return;

    // No button held any more: the release happened somewhere we never heard
    // about (another window, the desktop). Stop instead of resizing on hover.
    if (event.pointerType === "mouse" && event.buttons === 0) {
      this.finishDrag();
      return;
    }

    this.drag.position = this.coordinate(event);
    this.drag.moved = true;
    this.frame ||= requestAnimationFrame(() => this.updateDrag());
  }

  updateDrag() {
    this.frame = null;
    if (!this.drag) return;

    const { index, origin, position, start, direction } = this.drag;
    const delta = (((position - origin) * direction) / this.available) * 100;
    const next = this.resolve(start, index, delta);

    // Snapping a panel open or closed mid-drag gets a short transition so it doesn't jump.
    if (next.collapsed.some((collapsed, i) => collapsed !== this.layout.collapsed[i])) this.transition(150);
    this.apply(next);

    // Show a one-directional cursor when the handle can't travel any further.
    const travelled = next.sizes[index] - start.sizes[index];
    const blocked = Math.abs(delta - travelled) > 0.5 ? Math.sign(delta) * direction : 0;
    this.lockCursor(blocked);
  }

  endDrag(event) {
    if (!this.drag || event.pointerId !== this.drag.pointerId) return;

    if (!this.drag.moved && event.type === "pointerup") {
      this.lastTap = { handle: this.drag.handle, time: event.timeStamp };
    }
    this.finishDrag();
  }

  finishDrag({ revert = false } = {}) {
    if (!this.drag) return;

    const { handle, pointerId, start } = this.drag;
    if (this.frame) {
      cancelAnimationFrame(this.frame);
      this.updateDrag();
    }
    this.drag = null;

    if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
    handle.removeAttribute("data-dragging");
    this.element.removeAttribute("data-resizing");
    this.unlockCursor();

    if (revert) {
      this.transition();
      this.apply(start);
    }
    this.commit();
  }

  // ---------------------------------------------------------------------------
  // Keyboard
  // ---------------------------------------------------------------------------

  keydown(event) {
    const handle = this.handleFrom(event);
    if (!handle || event.target !== handle) return;

    if (this.drag) {
      if (event.key === "Escape") this.finishDrag({ revert: true });
      return;
    }
    if (event.altKey || event.ctrlKey || event.metaKey) return;

    const index = this.handleTargets.indexOf(handle);
    const step = this.keyboardStepValue * (event.shiftKey ? 3 : 1);
    const rtl = this.horizontal && getComputedStyle(this.element).direction === "rtl" ? -1 : 1;
    const [backward, forward] = this.horizontal ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"];

    this.measure();
    let next = null;

    switch (event.key) {
      case backward:
        next = this.resolve(this.layout, index, -step * rtl, { snap: true });
        break;
      case forward:
        next = this.resolve(this.layout, index, step * rtl, { snap: true });
        break;
      case "Home":
        next = this.resolve(this.layout, index, -100, { snap: true });
        break;
      case "End":
        next = this.resolve(this.layout, index, 100, { snap: true });
        break;
      case "Enter": {
        // Toggle the collapsible panel next to this handle (the one before it wins).
        const panel = [index, index + 1].find((i) => this.constraints[i].collapsible);
        if (panel === undefined) return;
        event.preventDefault();
        this.layout.collapsed[panel] ? this.expandPanel(panel) : this.collapsePanel(panel);
        return;
      }
      default:
        return;
    }

    event.preventDefault();
    // Key presses repeat, so plain steps are instant. Only snapping open or closed is animated.
    if (next.collapsed.some((collapsed, i) => collapsed !== this.layout.collapsed[i])) this.transition();
    this.apply(next);
    this.commit();
  }

  // ---------------------------------------------------------------------------
  // Layout
  // ---------------------------------------------------------------------------

  // Returns the layout you get by moving handle `index` by `delta` percent,
  // starting from `from`. A positive delta grows the panel before the handle.
  //
  // The growing panel takes space from the panel on the other side of the
  // handle first, then from the ones behind it once that one hits its minimum.
  // Collapsible panels snap closed past the halfway point between their minimum
  // and collapsed size (`snap: true` skips the halfway rule for the keyboard).
  resolve(from, index, delta, { snap = false } = {}) {
    if (Math.abs(delta) < PRECISION) return from;

    const limits = this.constraints;
    const sizes = [...from.sizes];
    const collapsed = [...from.collapsed];
    const direction = delta > 0 ? 1 : -1;
    const growing = delta > 0 ? index : index + 1;
    const shrinking = delta > 0 ? index + 1 : index;
    let wanted = Math.abs(delta);

    // A collapsed panel pops open to its minimum size once dragged far enough.
    const opening = collapsed[growing];
    const gap = limits[growing].min - limits[growing].collapsedSize;
    if (opening) {
      if (!snap && wanted < gap / 2) return from;
      wanted = Math.max(wanted, gap);
    }
    wanted = Math.min(wanted, limits[growing].max - sizes[growing]);

    let taken = 0;
    for (let i = shrinking; i >= 0 && i < sizes.length && wanted - taken > PRECISION; i += direction) {
      if (collapsed[i]) continue;

      const room = sizes[i] - limits[i].min;
      let take = Math.min(room, wanted - taken);

      if (i === shrinking && limits[i].collapsible) {
        const overshoot = wanted - taken - take;
        const closing = snap ? room < PRECISION : overshoot > (limits[i].min - limits[i].collapsedSize) / 2;
        const closed = sizes[i] - limits[i].collapsedSize;

        if (closing && sizes[growing] + closed <= limits[growing].max + PRECISION) {
          take = closed;
          collapsed[i] = true;
        }
        sizes[i] -= take;
        taken += take;
        break; // A collapsible panel absorbs the drag instead of pushing its neighbours
      }

      sizes[i] -= take;
      taken += take;
    }

    if (opening) {
      if (taken < gap - PRECISION) return from; // Not enough room to open
      collapsed[growing] = false;
    }
    sizes[growing] += taken;

    return { sizes, collapsed };
  }

  // Clamps every panel to its constraints and makes the sizes add up to 100 again.
  fit({ sizes, collapsed }) {
    const limits = this.constraints;
    sizes = sizes.map((size, i) => (collapsed[i] ? limits[i].collapsedSize : clamp(size, limits[i].min, limits[i].max)));

    // Hand the difference to the largest panels first.
    let rest = 100 - sum(sizes);
    const order = sizes
      .map((_, i) => i)
      .filter((i) => !collapsed[i])
      .sort((a, b) => sizes[b] - sizes[a]);

    for (const i of order) {
      if (Math.abs(rest) < PRECISION) break;
      const size = clamp(sizes[i] + rest, limits[i].min, limits[i].max);
      rest -= size - sizes[i];
      sizes[i] = size;
    }
    // The constraints can't all be satisfied (e.g. a very narrow screen): the largest panel gives.
    if (Math.abs(rest) >= PRECISION && order.length) sizes[order[0]] = Math.max(sizes[order[0]] + rest, 0);

    return { sizes, collapsed: [...collapsed] };
  }

  defaults() {
    const given = this.constraints.map((limit) => limit.defaultSize);
    const blanks = given.filter((size) => size === null).length;
    const share = blanks ? Math.max(100 - sum(given.filter((size) => size !== null)), 0) / blanks : 0;

    return this.fit({
      sizes: given.map((size) => (size === null ? share : size)),
      collapsed: given.map(() => false),
    });
  }

  // Writes a layout to the DOM: flex sizes, state attributes, ARIA values and size labels.
  apply(layout) {
    const previous = this.layout;
    const limits = this.constraints;
    this.layout = layout;

    this.panelTargets.forEach((panel, i) => {
      const collapsed = layout.collapsed[i];
      if (collapsed && previous && !previous.collapsed[i]) {
        this.remembered[i] = (this.drag ? this.drag.start : previous).sizes[i];
      }

      // flex-grow does the work, so the browser keeps the proportions on window resize for free.
      panel.style.flex = `${layout.sizes[i].toFixed(3)} 1 0px`;
      panel.toggleAttribute("data-collapsed", collapsed);
      // Fully hidden panels shouldn't keep their links and buttons in the tab order.
      panel.inert = collapsed && limits[i].collapsedSize < PRECISION;
    });

    this.handleTargets.forEach((handle, i) => {
      const others = sum(limits.map((limit, j) => (j === i ? 0 : limit.collapsedSize)));
      handle.setAttribute("aria-valuenow", Math.round(layout.sizes[i]));
      handle.setAttribute("aria-valuemin", Math.round(limits[i].collapsedSize));
      handle.setAttribute("aria-valuemax", Math.round(Math.min(limits[i].max, 100 - others)));
      handle.setAttribute("aria-controls", this.panelTargets[i].id);
    });

    this.syncTriggers();

    // Optional live labels: <span data-resize-target="size"></span> inside a panel.
    this.sizeTargets.forEach((label) => {
      const i = this.panelTargets.indexOf(label.closest('[data-resize-target~="panel"]'));
      if (i >= 0) label.textContent = `${Math.round(layout.sizes[i])}%`;
    });

    // Fires on every frame of a drag. Use resize:commit to save or sync the layout.
    if (previous && !this.same(previous, layout)) this.dispatch("change", { detail: this.detail() });
  }

  // Called once an interaction is finished (drag released, key pressed, panel toggled, …).
  commit({ persist = true } = {}) {
    if (this.committed && this.same(this.committed, this.layout)) return;
    this.committed = this.layout;
    if (persist) this.save();
    this.dispatch("commit", { detail: this.detail() });
  }

  detail() {
    return {
      sizes: [...this.layout.sizes],
      collapsed: [...this.layout.collapsed],
      panels: this.panelTargets.map((panel) => panel.id),
    };
  }

  // Keeps aria-expanded (and aria-controls) up to date on buttons that collapse a panel:
  // anything with aria-controls pointing at a collapsible panel, anywhere on the page,
  // plus resize#toggle buttons inside the group.
  syncTriggers() {
    const limits = this.constraints;
    const own = (element) => element.closest('[data-controller~="resize"]') === this.element;

    this.element.querySelectorAll('[data-action*="resize#toggle"]').forEach((trigger) => {
      if (!own(trigger) || trigger.hasAttribute("aria-controls")) return;
      const raw = trigger.getAttribute("data-resize-panel-param");
      const index = this.panelIndex(raw && /^\d+$/.test(raw) ? Number(raw) : raw);
      if (index >= 0) trigger.setAttribute("aria-controls", this.panelTargets[index].id);
    });

    this.panelTargets.forEach((panel, i) => {
      if (!limits[i].collapsible) return;
      document.querySelectorAll(`[aria-controls~="${CSS.escape(panel.id)}"]`).forEach((trigger) => {
        if (trigger.matches('[data-resize-target~="handle"]')) return;
        trigger.setAttribute("aria-expanded", String(!this.layout.collapsed[i]));
      });
    });
  }

  // Re-checks the layout after the group changed size (window resize, parent panel dragged, …).
  refresh() {
    if (this.drag) return;
    this.measure();
    if (this.available <= 0) return;

    if (this.initialLayout) {
      const untouched = this.same(this.initialLayout, this.layout);
      this.initialLayout = null;
      if (untouched) {
        this.apply(this.restore() || this.defaults());
        this.committed = this.layout;
        return;
      }
    }

    const next = this.fit(this.layout);
    if (!this.same(next, this.layout)) this.apply(next);
  }

  collapsePanel(index) {
    this.measure();
    const limit = this.constraints[index];
    if (!limit.collapsible || this.layout.collapsed[index]) return;

    // Use the handle after the panel, except for the last panel.
    const last = index === this.panelTargets.length - 1;
    const distance = this.layout.sizes[index] - limit.collapsedSize;
    this.transition();
    this.apply(this.resolve(this.layout, last ? index - 1 : index, last ? distance : -distance));
    this.commit();
  }

  expandPanel(index) {
    this.measure();
    const limit = this.constraints[index];
    if (!this.layout.collapsed[index]) return;

    const last = index === this.panelTargets.length - 1;
    const target = clamp(this.remembered[index] || limit.defaultSize || limit.min, limit.min, limit.max);
    const distance = Math.max(target - limit.collapsedSize, PRECISION * 2);
    this.transition();
    this.apply(this.resolve(this.layout, last ? index - 1 : index, last ? -distance : distance, { snap: true }));
    this.commit();
  }

  // ---------------------------------------------------------------------------
  // Helpers
  // ---------------------------------------------------------------------------

  get horizontal() {
    return this.orientationValue !== "vertical";
  }

  // Reads the available space and converts every panel's constraints to percentages.
  measure() {
    this.available = sum(
      this.panelTargets.map((panel) => {
        const rect = panel.getBoundingClientRect();
        return this.horizontal ? rect.width : rect.height;
      }),
    );

    this.constraints = this.panelTargets.map((panel) => {
      const data = panel.dataset;
      const collapsible = "resizeCollapsible" in data && data.resizeCollapsible !== "false";
      const min = clamp(this.percent(data.resizeMinSize, 0), 0, 100);

      return {
        min,
        max: clamp(this.percent(data.resizeMaxSize, 100), min, 100),
        collapsible,
        collapsedSize: collapsible ? Math.min(this.percent(data.resizeCollapsedSize, 0), min) : min,
        defaultSize: this.percent(data.resizeDefaultSize, null),
      };
    });
  }

  // "30" → 30, "240px" → 240 pixels as a percentage of the available space.
  percent(value, fallback) {
    if (value === undefined || value === "") return fallback;
    const number = parseFloat(value);
    if (Number.isNaN(number)) return fallback;
    if (!value.trim().endsWith("px")) return number;
    return this.available > 0 ? (number / this.available) * 100 : fallback;
  }

  coordinate(event) {
    return this.horizontal ? event.clientX : event.clientY;
  }

  // The handle an event came from, ignoring handles that belong to nested groups.
  handleFrom(event) {
    const handle = event.target.closest?.('[data-resize-target~="handle"]');
    return handle && this.handleTargets.includes(handle) ? handle : null;
  }

  panelIndex(param) {
    if (param === undefined || param === null || param === "") return this.constraints.findIndex((limit) => limit.collapsible);
    if (typeof param === "number") return param < this.panelTargets.length ? param : -1;
    return this.panelTargets.findIndex((panel) => panel.id === param);
  }

  same(a, b) {
    return (
      a.sizes.length === b.sizes.length &&
      a.sizes.every((size, i) => Math.abs(size - b.sizes[i]) < PRECISION && a.collapsed[i] === b.collapsed[i])
    );
  }

  // Animates the next layout change. Dragging itself is never animated.
  transition(duration = 220) {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    clearTimeout(this.transitionTimer);
    this.panelTargets.forEach((panel) => (panel.style.transition = `flex-grow ${duration}ms ${EASING}`));
    this.transitionTimer = setTimeout(() => {
      this.panelTargets.forEach((panel) => (panel.style.transition = ""));
    }, duration + 50);
  }

  // Shows a one-directional cursor on the dragged handle when it can't travel further.
  // Pointer capture makes the browser show the handle's cursor wherever the pointer is,
  // and the prevented pointerdown stops text selection, so nothing global is needed.
  // (A page-wide `* { cursor }` rule restyles every element: 50ms+ on large pages.)
  lockCursor(blocked = 0) {
    const handle = this.drag?.handle;
    if (!handle) return;
    const cursors = this.horizontal ? ["e-resize", "col-resize", "w-resize"] : ["s-resize", "row-resize", "n-resize"];
    const cursor = cursors[blocked + 1];
    if (handle.style.cursor === cursor) return;
    handle.style.cursor = cursor;
    this.cursorHandle = handle;
  }

  unlockCursor() {
    if (this.cursorHandle) this.cursorHandle.style.cursor = "";
    this.cursorHandle = null;
  }

  // ---------------------------------------------------------------------------
  // Persistence
  // ---------------------------------------------------------------------------
  //
  // Saved as {"sizes":[30,70],"collapsed":[false,false],"v":"…"}. "v" fingerprints the
  // panels' size options, so a saved layout is ignored once you change the defaults or limits.
  // With data-resize-storage-value="cookie" the same JSON goes into a cookie named after the
  // storage key, so Rails can read it and render the saved sizes on the server.

  get cookieName() {
    return this.storageKeyValue.replace(/[^\w-]/g, "_");
  }

  get signature() {
    const options = this.panelTargets
      .map((panel) => {
        const data = panel.dataset;
        // Missing attributes count as "-", so adding an empty data-resize-collapsible changes it too.
        return [data.resizeDefaultSize, data.resizeMinSize, data.resizeMaxSize, data.resizeCollapsible, data.resizeCollapsedSize]
          .map((value) => value ?? "-")
          .join(",");
      })
      .join("|");
    let hash = 5381;
    for (let i = 0; i < options.length; i++) hash = ((hash << 5) + hash + options.charCodeAt(i)) | 0;
    return (hash >>> 0).toString(36);
  }

  save() {
    if (!this.storageKeyValue) return;
    const data = JSON.stringify({
      sizes: this.layout.sizes.map((size) => Math.round(size * 100) / 100),
      collapsed: this.layout.collapsed,
      v: this.signature,
    });
    try {
      if (this.storageValue === "cookie") {
        const secure = location.protocol === "https:" ? "; Secure" : "";
        document.cookie = `${this.cookieName}=${encodeURIComponent(data)}; path=/; max-age=31536000; SameSite=Lax${secure}`;
      } else {
        localStorage.setItem(this.storageKeyValue, data);
      }
    } catch {
      // Storage can be unavailable (private mode, quota); the layout just won't persist.
    }
  }

  read() {
    if (this.storageValue === "cookie") {
      const prefix = `${this.cookieName}=`;
      const cookie = document.cookie.split("; ").find((part) => part.startsWith(prefix));
      return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null;
    }
    return localStorage.getItem(this.storageKeyValue);
  }

  restore() {
    if (!this.storageKeyValue) return null;
    try {
      const saved = JSON.parse(this.read());
      const count = this.panelTargets.length;
      const valid =
        Array.isArray(saved?.sizes) &&
        Array.isArray(saved?.collapsed) &&
        saved.sizes.length === count &&
        saved.collapsed.length === count &&
        saved.sizes.every((size) => Number.isFinite(size)) &&
        (saved.v === undefined || saved.v === this.signature);
      if (!valid) return null;

      return this.fit({
        sizes: saved.sizes,
        collapsed: saved.collapsed.map((collapsed, i) => Boolean(collapsed) && this.constraints[i].collapsible),
      });
    } catch {
      return null;
    }
  }

  forget() {
    if (!this.storageKeyValue) return;
    try {
      if (this.storageValue === "cookie") {
        document.cookie = `${this.cookieName}=; path=/; max-age=0; SameSite=Lax`;
      } else {
        localStorage.removeItem(this.storageKeyValue);
      }
    } catch {
      // Nothing to clean up.
    }
  }

  // Another tab saved a new layout under the same key: follow along.
  syncFromStorage(event) {
    if (!this.storageKeyValue || this.storageValue === "cookie" || event.key !== this.storageKeyValue || this.drag) return;
    this.measure();
    if (this.available <= 0) return;

    const next = event.newValue === null ? this.defaults() : this.restore();
    if (!next || this.same(next, this.layout)) return;
    this.transition();
    this.apply(next);
    this.committed = this.layout;
  }

  // ---------------------------------------------------------------------------
  // Setup warnings
  // ---------------------------------------------------------------------------

  // Catches the usual copy-paste mistakes early, with the element to look at.
  warnAboutSetup() {
    const warn = (message, element = this.element) => console.warn(`[resize] ${message}`, element);
    const panels = this.panelTargets;
    const handles = this.handleTargets;

    if (handles.length !== panels.length - 1) {
      warn(`Found ${panels.length} panel(s) and ${handles.length} handle(s). Panels and handles must alternate, with one handle between each pair of panels.`);
    }

    const container = panels[0]?.parentElement;
    if ([...panels, ...handles].some((element) => element.parentElement !== container)) {
      warn("Panels and handles must be direct children of the same flex container.");
    } else if (container && !getComputedStyle(container).display.includes("flex")) {
      warn(`The element holding the panels must be a flex container (add "flex${this.horizontal ? "" : " flex-col"}").`, container);
    }

    const sides = this.horizontal ? ["Left", "Right"] : ["Top", "Bottom"];
    panels.forEach((panel) => {
      const style = getComputedStyle(panel);
      const extra = sides.reduce((total, side) => total + (parseFloat(style[`padding${side}`]) || 0) + (parseFloat(style[`border${side}Width`]) || 0), 0);
      if (extra > 0) warn("Panels should not have padding or borders along the resize direction. Put them on an element inside the panel, or the sizes will be off.", panel);

      const { resizeMinSize: min, resizeMaxSize: max } = panel.dataset;
      if (min && max && min.trim().endsWith("px") === max.trim().endsWith("px") && parseFloat(min) > parseFloat(max)) {
        warn(`data-resize-min-size (${min}) is larger than data-resize-max-size (${max}).`, panel);
      }
    });

    const defaults = this.constraints.map((limit) => limit.defaultSize).filter((size) => size !== null);
    if (sum(defaults) > 100 + PRECISION) warn(`The default sizes add up to ${Math.round(sum(defaults))}%, more than 100%.`);

    handles.forEach((handle) => {
      if (!handle.hasAttribute("aria-label") && !handle.hasAttribute("aria-labelledby")) {
        warn('Give each handle an aria-label (for example "Resize sidebar") so screen readers can name it.', handle);
      }
    });
  }
}

Examples

Basic Resize

Two panels and one handle. Drag it, or focus it and use the arrow keys. Double-click the handle to return to the default sizes. The handle is a 1px line with a much larger invisible hit area, so it is easy to grab without looking heavy.

One 40%
Two 60%
<div class="w-full max-w-2xl">
  <%# Drag the handle, or focus it and use the arrow keys. Double-click to reset. %>
  <div data-controller="resize" class="flex h-56 w-full overflow-hidden rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
    <div data-resize-target="panel" data-resize-default-size="40" data-resize-min-size="20" style="flex: 40 1 0px" class="flex min-w-0 flex-col items-center justify-center gap-1 overflow-hidden">
      <span class="text-sm font-medium text-neutral-900 dark:text-neutral-100">One</span>
      <span data-resize-target="size" class="text-xs tabular-nums text-neutral-500 dark:text-neutral-400">40%</span>
    </div>

    <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize panels"
         class="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-black/10 outline-none before:absolute before:inset-y-0 before:-left-px before:-right-px before:bg-neutral-400 before:opacity-0 before:transition-opacity before:duration-150 after:absolute after:inset-y-0 after:-left-1.5 after:-right-1.5 hover:before:opacity-100 hover:before:delay-150 focus-visible:before:delay-0 data-[dragging]:before:delay-0 focus-visible:before:bg-neutral-900 focus-visible:before:opacity-100 data-[dragging]:before:bg-neutral-900 data-[dragging]:before:opacity-100 pointer-coarse:after:-left-5 pointer-coarse:after:-right-5 dark:bg-white/10 dark:before:bg-neutral-500 dark:focus-visible:before:bg-white dark:data-[dragging]:before:bg-white"></div>

    <div data-resize-target="panel" data-resize-min-size="20" style="flex: 60 1 0px" class="flex min-w-0 flex-col items-center justify-center gap-1 overflow-hidden bg-neutral-50 dark:bg-neutral-950/40">
      <span class="text-sm font-medium text-neutral-900 dark:text-neutral-100">Two</span>
      <span data-resize-target="size" class="text-xs tabular-nums text-neutral-500 dark:text-neutral-400">60%</span>
    </div>
  </div>
</div>

Vertical with Grip

Set data-resize-orientation-value="vertical" to stack panels. This handle adds a visible grip, which is easier to discover and to grab on touch screens.

Query
SELECT plan, COUNT(*) AS customers, SUM(mrr) AS mrr
FROM subscriptions
WHERE status = 'active'
GROUP BY plan
ORDER BY mrr DESC;
Results 4 rows · 12 ms
plancustomersmrr
Team412$40,376
Business96$28,704
Pro1,284$24,396
Starter2,031$18,279
<div class="w-full max-w-2xl">
  <div data-controller="resize" data-resize-orientation-value="vertical" class="flex h-96 w-full flex-col overflow-hidden rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
    <%# Query editor %>
    <div data-resize-target="panel" data-resize-default-size="45" data-resize-min-size="25" style="flex: 45 1 0px" class="flex min-h-0 flex-col overflow-hidden">
      <div class="flex shrink-0 items-center justify-between border-b border-black/5 px-4 py-2 dark:border-white/5">
        <span class="text-xs font-medium text-neutral-500 dark:text-neutral-400">Query</span>
        <button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-2.5 py-1 text-xs font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-3" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><path d="M3 1.8v8.4a.6.6 0 0 0 .92.5l6.6-4.2a.6.6 0 0 0 0-1L3.92 1.3A.6.6 0 0 0 3 1.8Z"/></svg>
          Run
        </button>
      </div>
      <pre class="small-scrollbar min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-xs leading-5 text-neutral-700 dark:text-neutral-300"><span class="text-neutral-400 dark:text-neutral-500">SELECT</span> plan, <span class="text-neutral-400 dark:text-neutral-500">COUNT</span>(*) <span class="text-neutral-400 dark:text-neutral-500">AS</span> customers, <span class="text-neutral-400 dark:text-neutral-500">SUM</span>(mrr) <span class="text-neutral-400 dark:text-neutral-500">AS</span> mrr
<span class="text-neutral-400 dark:text-neutral-500">FROM</span> subscriptions
<span class="text-neutral-400 dark:text-neutral-500">WHERE</span> status = 'active'
<span class="text-neutral-400 dark:text-neutral-500">GROUP BY</span> plan
<span class="text-neutral-400 dark:text-neutral-500">ORDER BY</span> mrr <span class="text-neutral-400 dark:text-neutral-500">DESC</span>;</pre>
    </div>

    <%# Handle with a visible grip %>
    <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize query and results"
         class="group/handle relative z-10 h-px w-full shrink-0 cursor-row-resize touch-none bg-black/10 outline-none transition-colors duration-150 ease-out hover:bg-black/20 focus-visible:bg-neutral-900 data-[dragging]:bg-neutral-900 motion-reduce:transition-none after:absolute after:inset-x-0 after:-top-2 after:-bottom-2 pointer-coarse:after:-top-5 pointer-coarse:after:-bottom-5 dark:bg-white/10 dark:hover:bg-white/25 dark:focus-visible:bg-white dark:data-[dragging]:bg-white">
      <span class="absolute left-1/2 top-1/2 flex h-3.5 w-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-black/10 bg-white text-neutral-400 shadow-xs transition-[color,border-color,scale] duration-150 ease-out group-hover/handle:border-black/20 group-hover/handle:text-neutral-700 group-focus-visible/handle:border-neutral-900 group-focus-visible/handle:text-neutral-900 group-data-[dragging]/handle:scale-110 motion-reduce:transition-none group-data-[dragging]/handle:border-neutral-900 group-data-[dragging]/handle:text-neutral-900 dark:border-white/15 dark:bg-neutral-800 dark:text-neutral-500 dark:group-hover/handle:border-white/30 dark:group-hover/handle:text-neutral-200 dark:group-focus-visible/handle:border-white dark:group-focus-visible/handle:text-white dark:group-data-[dragging]/handle:border-white dark:group-data-[dragging]/handle:text-white">
        <svg xmlns="http://www.w3.org/2000/svg" class="h-1.5 w-3.5" viewBox="0 0 14 6" fill="currentColor" aria-hidden="true"><circle cx="3" cy="1" r="1"/><circle cx="7" cy="1" r="1"/><circle cx="11" cy="1" r="1"/><circle cx="3" cy="5" r="1"/><circle cx="7" cy="5" r="1"/><circle cx="11" cy="5" r="1"/></svg>
      </span>
    </div>

    <%# Results %>
    <div data-resize-target="panel" data-resize-min-size="25" style="flex: 55 1 0px" class="flex min-h-0 flex-col overflow-hidden bg-neutral-50 dark:bg-neutral-950/40">
      <div class="flex shrink-0 items-center justify-between px-4 pb-1 pt-3">
        <span class="text-xs font-medium text-neutral-500 dark:text-neutral-400">Results</span>
        <span class="text-xs tabular-nums text-neutral-400 dark:text-neutral-500">4 rows · 12 ms</span>
      </div>
      <div class="small-scrollbar min-h-0 flex-1 overflow-auto px-4 pb-3">
        <table class="w-full text-left text-xs">
          <thead class="sticky top-0 bg-neutral-50 text-neutral-500 dark:bg-neutral-950 dark:text-neutral-400">
            <tr><th class="py-1.5 font-medium">plan</th><th class="py-1.5 text-right font-medium">customers</th><th class="py-1.5 text-right font-medium">mrr</th></tr>
          </thead>
          <tbody class="divide-y divide-black/5 tabular-nums text-neutral-800 dark:divide-white/5 dark:text-neutral-200">
            <tr><td class="py-1.5">Team</td><td class="py-1.5 text-right">412</td><td class="py-1.5 text-right">$40,376</td></tr>
            <tr><td class="py-1.5">Business</td><td class="py-1.5 text-right">96</td><td class="py-1.5 text-right">$28,704</td></tr>
            <tr><td class="py-1.5">Pro</td><td class="py-1.5 text-right">1,284</td><td class="py-1.5 text-right">$24,396</td></tr>
            <tr><td class="py-1.5">Starter</td><td class="py-1.5 text-right">2,031</td><td class="py-1.5 text-right">$18,279</td></tr>
          </tbody>
        </table>
      </div>
    </div>
  </div>
</div>

Min and Max Sizes

Three panels, each with its own data-resize-min-size and data-resize-max-size. When a panel reaches its minimum, the handle keeps going and starts pushing the next panel. The cursor turns one-directional when a handle cannot travel any further.

Filters

15% – 35%

25%

Results

At least 30%

50%

Inspector

15% – 40%

25%
<div class="w-full max-w-3xl">
  <%# Each panel declares its own limits. Dragging a handle past a panel's minimum starts pushing the next panel. %>
  <div data-controller="resize" class="flex h-56 w-full overflow-hidden rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
    <div data-resize-target="panel" data-resize-default-size="25" data-resize-min-size="15" data-resize-max-size="35" style="flex: 25 1 0px" class="min-w-0 overflow-hidden">
      <div class="flex h-full flex-col justify-between p-4">
        <div class="min-w-0">
          <p class="truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">Filters</p>
          <p class="truncate text-xs text-neutral-500 dark:text-neutral-400">15% – 35%</p>
        </div>
        <span data-resize-target="size" class="text-2xl font-semibold tabular-nums tracking-tight text-neutral-900 dark:text-white">25%</span>
      </div>
    </div>

    <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize filters"
         class="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-black/10 outline-none before:absolute before:inset-y-0 before:-left-px before:-right-px before:bg-neutral-400 before:opacity-0 before:transition-opacity before:duration-150 after:absolute after:inset-y-0 after:-left-1.5 after:-right-1.5 hover:before:opacity-100 hover:before:delay-150 focus-visible:before:delay-0 data-[dragging]:before:delay-0 focus-visible:before:bg-neutral-900 focus-visible:before:opacity-100 data-[dragging]:before:bg-neutral-900 data-[dragging]:before:opacity-100 pointer-coarse:after:-left-5 pointer-coarse:after:-right-5 dark:bg-white/10 dark:before:bg-neutral-500 dark:focus-visible:before:bg-white dark:data-[dragging]:before:bg-white"></div>

    <div data-resize-target="panel" data-resize-default-size="50" data-resize-min-size="30" style="flex: 50 1 0px" class="min-w-0 overflow-hidden bg-neutral-50 dark:bg-neutral-950/40">
      <div class="flex h-full flex-col justify-between p-4">
        <div class="min-w-0">
          <p class="truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">Results</p>
          <p class="truncate text-xs text-neutral-500 dark:text-neutral-400">At least 30%</p>
        </div>
        <span data-resize-target="size" class="text-2xl font-semibold tabular-nums tracking-tight text-neutral-900 dark:text-white">50%</span>
      </div>
    </div>

    <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize inspector"
         class="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-black/10 outline-none before:absolute before:inset-y-0 before:-left-px before:-right-px before:bg-neutral-400 before:opacity-0 before:transition-opacity before:duration-150 after:absolute after:inset-y-0 after:-left-1.5 after:-right-1.5 hover:before:opacity-100 hover:before:delay-150 focus-visible:before:delay-0 data-[dragging]:before:delay-0 focus-visible:before:bg-neutral-900 focus-visible:before:opacity-100 data-[dragging]:before:bg-neutral-900 data-[dragging]:before:opacity-100 pointer-coarse:after:-left-5 pointer-coarse:after:-right-5 dark:bg-white/10 dark:before:bg-neutral-500 dark:focus-visible:before:bg-white dark:data-[dragging]:before:bg-white"></div>

    <div data-resize-target="panel" data-resize-default-size="25" data-resize-min-size="15" data-resize-max-size="40" style="flex: 25 1 0px" class="min-w-0 overflow-hidden">
      <div class="flex h-full flex-col justify-between p-4">
        <div class="min-w-0">
          <p class="truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">Inspector</p>
          <p class="truncate text-xs text-neutral-500 dark:text-neutral-400">15% – 40%</p>
        </div>
        <span data-resize-target="size" class="text-2xl font-semibold tabular-nums tracking-tight text-neutral-900 dark:text-white">25%</span>
      </div>
    </div>
  </div>
</div>

Pill Handle

Panels do not have to share a border. Here two separate cards sit side by side with a floating pill in the gap that stretches when you hover or drag it.

This week

$12,480

+8.2% vs last week

Top products

  • Linen overshirt$3,920
  • Canvas weekender bag$2,745
  • Merino crew sweater$2,310
  • Everyday leather belt$1,640
  • Ribbed beanie$985
<div class="w-full max-w-2xl">
  <%# Two separate cards with a floating pill handle in the gap between them. %>
  <div data-controller="resize" class="flex h-64 w-full">
    <div data-resize-target="panel" data-resize-default-size="50" data-resize-min-size="30" style="flex: 50 1 0px" class="min-w-0">
      <div class="h-full overflow-hidden rounded-xl border border-black/10 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-neutral-900">
      <p class="text-xs font-medium text-neutral-500 dark:text-neutral-400">This week</p>
      <p class="mt-1 text-2xl font-semibold tabular-nums tracking-tight text-neutral-900 dark:text-white">$12,480</p>
      <p class="mt-1 truncate text-xs text-neutral-500 dark:text-neutral-400"><span class="font-medium text-emerald-600 dark:text-emerald-400">+8.2%</span> vs last week</p>
      <div class="mt-6 flex h-24 items-end gap-1.5" aria-hidden="true">
        <span class="h-[45%] flex-1 rounded-sm bg-neutral-200 dark:bg-neutral-700"></span>
        <span class="h-[60%] flex-1 rounded-sm bg-neutral-200 dark:bg-neutral-700"></span>
        <span class="h-[52%] flex-1 rounded-sm bg-neutral-200 dark:bg-neutral-700"></span>
        <span class="h-[78%] flex-1 rounded-sm bg-neutral-200 dark:bg-neutral-700"></span>
        <span class="h-[66%] flex-1 rounded-sm bg-neutral-200 dark:bg-neutral-700"></span>
        <span class="h-[88%] flex-1 rounded-sm bg-neutral-200 dark:bg-neutral-700"></span>
        <span class="h-full flex-1 rounded-sm bg-neutral-900 dark:bg-white"></span>
      </div>
      </div>
    </div>

    <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize cards"
         class="group/handle relative z-10 flex w-4 shrink-0 cursor-col-resize touch-none items-center justify-center outline-none after:absolute after:inset-y-0 after:-left-1 after:-right-1 pointer-coarse:after:-left-3 pointer-coarse:after:-right-3">
      <span class="h-8 w-1 rounded-full bg-neutral-300 transition-[height,background-color] duration-200 ease-out group-hover/handle:h-12 group-hover/handle:bg-neutral-400 group-focus-visible/handle:h-12 group-focus-visible/handle:bg-neutral-900 group-data-[dragging]/handle:h-16 group-data-[dragging]/handle:bg-neutral-900 motion-reduce:transition-none dark:bg-neutral-600 dark:group-hover/handle:bg-neutral-400 dark:group-focus-visible/handle:bg-white dark:group-data-[dragging]/handle:bg-white"></span>
    </div>

    <div data-resize-target="panel" data-resize-min-size="30" style="flex: 50 1 0px" class="min-w-0">
      <div class="h-full overflow-hidden rounded-xl border border-black/10 bg-white p-5 shadow-xs dark:border-white/10 dark:bg-neutral-900">
      <p class="text-xs font-medium text-neutral-500 dark:text-neutral-400">Top products</p>
      <ul class="mt-3 space-y-3 text-sm">
        <li class="flex items-center justify-between gap-3"><span class="truncate text-neutral-800 dark:text-neutral-200">Linen overshirt</span><span class="tabular-nums text-neutral-500 dark:text-neutral-400">$3,920</span></li>
        <li class="flex items-center justify-between gap-3"><span class="truncate text-neutral-800 dark:text-neutral-200">Canvas weekender bag</span><span class="tabular-nums text-neutral-500 dark:text-neutral-400">$2,745</span></li>
        <li class="flex items-center justify-between gap-3"><span class="truncate text-neutral-800 dark:text-neutral-200">Merino crew sweater</span><span class="tabular-nums text-neutral-500 dark:text-neutral-400">$2,310</span></li>
        <li class="flex items-center justify-between gap-3"><span class="truncate text-neutral-800 dark:text-neutral-200">Everyday leather belt</span><span class="tabular-nums text-neutral-500 dark:text-neutral-400">$1,640</span></li>
        <li class="flex items-center justify-between gap-3"><span class="truncate text-neutral-800 dark:text-neutral-200">Ribbed beanie</span><span class="tabular-nums text-neutral-500 dark:text-neutral-400">$985</span></li>
      </ul>
      </div>
    </div>
  </div>
</div>

Nested Layout

Put a second data-controller="resize" inside a panel to mix directions, like an editor with a file explorer and a terminal. Each group manages its own handles.

invoices_controller.rb
class InvoicesController < ApplicationController
  def index
    @invoices = Current.account.invoices.recent
  end

  def show
    @invoice = Current.account.invoices.find(params[:id])
  end
end

Terminal

$ bin/rails test test/controllers/invoices_controller_test.rb
Running 6 tests in a single process
......
6 runs, 14 assertions, 0 failures, 0 errors
<div class="w-full max-w-3xl">
  <%# A horizontal group whose second panel contains a vertical group. Each group is its own controller. %>
  <div data-controller="resize" class="flex h-96 w-full overflow-hidden rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
    <%# File explorer %>
    <nav data-resize-target="panel" data-resize-default-size="28" data-resize-min-size="18" data-resize-max-size="45" style="flex: 28 1 0px" aria-label="Files" class="min-w-0 overflow-hidden bg-neutral-50 py-3 dark:bg-neutral-950/40">
      <p class="truncate px-4 pb-2 text-[11px] font-medium text-neutral-400 dark:text-neutral-500">Explorer</p>
      <ul class="space-y-px px-2 text-xs text-neutral-600 dark:text-neutral-400">
        <li class="flex items-center gap-2 rounded-md px-2 py-1.5"><svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 1.75H4.75a1 1 0 0 0-1 1v10.5a1 1 0 0 0 1 1h6.5a1 1 0 0 0 1-1V5L9 1.75Z" /><path d="M9 1.75V5h3.25" /></svg><span class="truncate">application_controller.rb</span></li>
        <li class="flex items-center gap-2 rounded-md bg-black/5 px-2 py-1.5 font-medium text-neutral-900 dark:bg-white/10 dark:text-white"><svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 1.75H4.75a1 1 0 0 0-1 1v10.5a1 1 0 0 0 1 1h6.5a1 1 0 0 0 1-1V5L9 1.75Z" /><path d="M9 1.75V5h3.25" /></svg><span class="truncate">invoices_controller.rb</span></li>
        <li class="flex items-center gap-2 rounded-md px-2 py-1.5"><svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 1.75H4.75a1 1 0 0 0-1 1v10.5a1 1 0 0 0 1 1h6.5a1 1 0 0 0 1-1V5L9 1.75Z" /><path d="M9 1.75V5h3.25" /></svg><span class="truncate">invoice.rb</span></li>
        <li class="flex items-center gap-2 rounded-md px-2 py-1.5"><svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 1.75H4.75a1 1 0 0 0-1 1v10.5a1 1 0 0 0 1 1h6.5a1 1 0 0 0 1-1V5L9 1.75Z" /><path d="M9 1.75V5h3.25" /></svg><span class="truncate">invoice_mailer.rb</span></li>
        <li class="flex items-center gap-2 rounded-md px-2 py-1.5"><svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 1.75H4.75a1 1 0 0 0-1 1v10.5a1 1 0 0 0 1 1h6.5a1 1 0 0 0 1-1V5L9 1.75Z" /><path d="M9 1.75V5h3.25" /></svg><span class="truncate">routes.rb</span></li>
      </ul>
    </nav>

    <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize file explorer"
         class="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-black/10 outline-none before:absolute before:inset-y-0 before:-left-px before:-right-px before:bg-neutral-400 before:opacity-0 before:transition-opacity before:duration-150 after:absolute after:inset-y-0 after:-left-1.5 after:-right-1.5 hover:before:opacity-100 hover:before:delay-150 focus-visible:before:delay-0 data-[dragging]:before:delay-0 focus-visible:before:bg-neutral-900 focus-visible:before:opacity-100 data-[dragging]:before:bg-neutral-900 data-[dragging]:before:opacity-100 pointer-coarse:after:-left-5 pointer-coarse:after:-right-5 dark:bg-white/10 dark:before:bg-neutral-500 dark:focus-visible:before:bg-white dark:data-[dragging]:before:bg-white"></div>

    <%# Editor + terminal: a nested, vertical group %>
    <div data-resize-target="panel" data-resize-min-size="40" style="flex: 72 1 0px" class="min-w-0 overflow-hidden">
      <div data-controller="resize" data-resize-orientation-value="vertical" class="flex h-full flex-col">
        <div data-resize-target="panel" data-resize-default-size="65" data-resize-min-size="30" style="flex: 65 1 0px" class="flex min-h-0 flex-col overflow-hidden">
          <div class="flex shrink-0 border-b border-black/5 dark:border-white/5">
            <span class="truncate border-r border-black/5 px-4 py-2 text-xs font-medium text-neutral-900 dark:border-white/5 dark:text-white">invoices_controller.rb</span>
          </div>
          <pre class="small-scrollbar min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-xs leading-5 text-neutral-700 dark:text-neutral-300"><span class="text-neutral-400 dark:text-neutral-500">class</span> InvoicesController &lt; ApplicationController
  <span class="text-neutral-400 dark:text-neutral-500">def</span> index
    @invoices = Current.account.invoices.recent
  <span class="text-neutral-400 dark:text-neutral-500">end</span>

  <span class="text-neutral-400 dark:text-neutral-500">def</span> show
    @invoice = Current.account.invoices.find(params[:id])
  <span class="text-neutral-400 dark:text-neutral-500">end</span>
<span class="text-neutral-400 dark:text-neutral-500">end</span></pre>
        </div>

        <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize terminal"
             class="relative z-10 h-px w-full shrink-0 cursor-row-resize touch-none bg-black/10 outline-none before:absolute before:inset-x-0 before:-top-px before:-bottom-px before:bg-neutral-400 before:opacity-0 before:transition-opacity before:duration-150 after:absolute after:inset-x-0 after:-top-1.5 after:-bottom-1.5 hover:before:opacity-100 hover:before:delay-150 focus-visible:before:delay-0 data-[dragging]:before:delay-0 focus-visible:before:bg-neutral-900 focus-visible:before:opacity-100 data-[dragging]:before:bg-neutral-900 data-[dragging]:before:opacity-100 pointer-coarse:after:-top-5 pointer-coarse:after:-bottom-5 dark:bg-white/10 dark:before:bg-neutral-500 dark:focus-visible:before:bg-white dark:data-[dragging]:before:bg-white"></div>

        <div data-resize-target="panel" data-resize-min-size="15" style="flex: 35 1 0px" class="flex min-h-0 flex-col overflow-hidden bg-neutral-50 dark:bg-neutral-950/40">
          <p class="shrink-0 truncate px-4 pb-1 pt-2.5 text-[11px] font-medium text-neutral-400 dark:text-neutral-500">Terminal</p>
          <pre class="small-scrollbar min-h-0 flex-1 overflow-auto px-4 pb-3 font-mono text-xs leading-5 text-neutral-600 dark:text-neutral-400">$ bin/rails test test/controllers/invoices_controller_test.rb
Running 6 tests in a single process
......
<span class="text-emerald-600 dark:text-emerald-400">6 runs, 14 assertions, 0 failures, 0 errors</span></pre>
        </div>
      </div>
    </div>
  </div>
</div>

Collapsible Sidebar

Add data-resize-collapsible to a panel: drag it past half of its minimum size and it snaps shut, drag back out to reopen it. Press Enter on the handle or use the toggle button, which restores the width the sidebar had before.

Introduction

Harbor is a deployment toolkit for Rails apps. Point it at a server, and it builds, ships and restarts your app with zero downtime.

This guide walks through your first deploy. It takes about ten minutes and assumes you already have a Rails 7.1+ app and SSH access to a server.

<div class="w-full max-w-3xl">
  <%# Drag the sidebar past half of its minimum width and it snaps closed. Press Enter on the handle, or use the button, to toggle it. %>
  <div data-controller="resize" class="flex h-80 w-full overflow-hidden rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
    <nav id="resize-docs-sidebar" data-resize-target="panel" data-resize-default-size="32" data-resize-min-size="160px" data-resize-max-size="50" data-resize-collapsible style="flex: 32 1 0px" aria-label="Documentation" class="min-w-0 overflow-hidden bg-neutral-50 dark:bg-neutral-950/40">
      <%# A fixed minimum width keeps the content from reflowing while the panel slides shut. %>
      <div class="min-w-40 p-3">
        <p class="px-2 pb-2 pt-1 text-[11px] font-medium text-neutral-400 dark:text-neutral-500">Getting started</p>
        <ul class="text-sm">
          <li><a href="#" class="block truncate rounded-md bg-black/5 px-2 py-1.5 font-medium text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:bg-white/10 dark:text-white dark:focus-visible:outline-neutral-200">Introduction</a></li>
          <li><a href="#" class="block truncate rounded-md border-y-2 border-transparent bg-clip-padding px-2 py-1.5 text-neutral-600 hover:bg-black/5 hover:text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:outline-neutral-200">Installation</a></li>
          <li><a href="#" class="block truncate rounded-md border-y-2 border-transparent bg-clip-padding px-2 py-1.5 text-neutral-600 hover:bg-black/5 hover:text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:outline-neutral-200">Configuration</a></li>
          <li><a href="#" class="block truncate rounded-md border-y-2 border-transparent bg-clip-padding px-2 py-1.5 text-neutral-600 hover:bg-black/5 hover:text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:outline-neutral-200">Deploying</a></li>
          <li><a href="#" class="block truncate rounded-md border-y-2 border-transparent bg-clip-padding px-2 py-1.5 text-neutral-600 hover:bg-black/5 hover:text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:outline-neutral-200">Upgrading</a></li>
        </ul>
      </div>
    </nav>

    <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize sidebar"
         class="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-black/10 outline-none [[data-collapsed]+&]:bg-transparent before:absolute before:inset-y-0 before:-left-px before:-right-px before:bg-neutral-400 before:opacity-0 before:transition-opacity before:duration-150 after:absolute after:inset-y-0 after:-left-1.5 after:-right-1.5 hover:before:opacity-100 hover:before:delay-150 focus-visible:before:delay-0 data-[dragging]:before:delay-0 focus-visible:before:bg-neutral-900 focus-visible:before:opacity-100 data-[dragging]:before:bg-neutral-900 data-[dragging]:before:opacity-100 pointer-coarse:after:-left-5 pointer-coarse:after:-right-5 dark:bg-white/10 dark:before:bg-neutral-500 dark:focus-visible:before:bg-white dark:data-[dragging]:before:bg-white"></div>

    <main data-resize-target="panel" data-resize-min-size="40" style="flex: 68 1 0px" class="min-w-0 overflow-hidden">
      <div class="flex items-center gap-2 border-b border-black/5 px-3 py-2 dark:border-white/5">
        <button type="button" data-action="resize#toggle" data-resize-panel-param="resize-docs-sidebar" aria-controls="resize-docs-sidebar" aria-label="Toggle sidebar"
                class="flex size-7 shrink-0 items-center justify-center rounded-md bg-transparent p-0 text-neutral-500 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-400 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.75" width="12.5" height="10.5" rx="2" /><path d="M6 2.75v10.5" /></svg>
        </button>
        <span class="truncate text-sm font-medium text-neutral-900 dark:text-white">Introduction</span>
      </div>
      <div class="space-y-3 p-5 text-sm leading-6 text-neutral-600 dark:text-neutral-400">
        <p>Harbor is a deployment toolkit for Rails apps. Point it at a server, and it builds, ships and restarts your app with zero downtime.</p>
        <p>This guide walks through your first deploy. It takes about ten minutes and assumes you already have a Rails 7.1+ app and SSH access to a server.</p>
      </div>
    </main>
  </div>
</div>

Persisted Layout

Give the group a data-resize-storage-key-value and the layout is saved to localStorage. Resize the editor, reload the page, and it comes back exactly as you left it. The button calls resize#reset.

release-notes.md

Preview

Version 2.4

Faster deploys and a new dashboard.

  • Builds are cached per branch
  • Rollbacks finish in under 5 seconds
  • Dark mode for the dashboard
<div class="w-full max-w-3xl">
  <%# data-resize-storage-key-value saves the layout to localStorage. Resize, reload the page, and it is still there. %>
  <div data-controller="resize" data-resize-storage-key-value="resize:markdown-editor" class="overflow-hidden rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
    <div class="flex items-center justify-between gap-3 border-b border-black/10 px-4 py-2 dark:border-white/10">
      <span class="truncate text-sm font-medium text-neutral-900 dark:text-white">release-notes.md</span>
      <button type="button" data-action="resize#reset"
              class="flex shrink-0 items-center justify-center gap-1.5 rounded-lg bg-transparent px-2.5 py-1 text-xs font-medium whitespace-nowrap text-neutral-800 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-50 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200">
        Reset layout
      </button>
    </div>

    <%# Panels and handles can live in a wrapper, as long as the wrapper is the flex container. %>
    <div class="flex h-64">
      <div data-resize-target="panel" data-resize-default-size="50" data-resize-min-size="25" style="flex: 50 1 0px" class="flex min-w-0 flex-col overflow-hidden">
        <label for="resize-markdown" class="shrink-0 px-4 pt-3 text-[11px] font-medium text-neutral-400 dark:text-neutral-500">Markdown</label>
        <textarea id="resize-markdown" spellcheck="false" class="small-scrollbar min-h-0 w-full flex-1 resize-none border-0 bg-transparent px-4 py-2 font-mono text-xs leading-5 text-neutral-700 outline-none focus:ring-0 dark:text-neutral-300"># Version 2.4

Faster deploys and a new dashboard.

- Builds are cached per branch
- Rollbacks finish in under 5 seconds
- Dark mode for the dashboard</textarea>
      </div>

      <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize editor and preview"
           class="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-black/10 outline-none before:absolute before:inset-y-0 before:-left-px before:-right-px before:bg-neutral-400 before:opacity-0 before:transition-opacity before:duration-150 after:absolute after:inset-y-0 after:-left-1.5 after:-right-1.5 hover:before:opacity-100 hover:before:delay-150 focus-visible:before:delay-0 data-[dragging]:before:delay-0 focus-visible:before:bg-neutral-900 focus-visible:before:opacity-100 data-[dragging]:before:bg-neutral-900 data-[dragging]:before:opacity-100 pointer-coarse:after:-left-5 pointer-coarse:after:-right-5 dark:bg-white/10 dark:before:bg-neutral-500 dark:focus-visible:before:bg-white dark:data-[dragging]:before:bg-white"></div>

      <div data-resize-target="panel" data-resize-min-size="25" style="flex: 50 1 0px" class="flex min-w-0 flex-col overflow-hidden bg-neutral-50 dark:bg-neutral-950/40">
        <p class="shrink-0 px-4 pt-3 text-[11px] font-medium text-neutral-400 dark:text-neutral-500">Preview</p>
        <div class="small-scrollbar min-h-0 flex-1 overflow-auto px-4 py-2 text-sm text-neutral-600 dark:text-neutral-400">
          <p class="text-base font-semibold text-neutral-900 dark:text-white">Version 2.4</p>
          <p class="mt-1.5">Faster deploys and a new dashboard.</p>
          <ul class="mt-2 list-disc space-y-1 pl-5">
            <li>Builds are cached per branch</li>
            <li>Rollbacks finish in under 5 seconds</li>
            <li>Dark mode for the dashboard</li>
          </ul>
        </div>
      </div>
    </div>
  </div>
</div>

Mail Layout

A full three-column app layout. Sizes accept pixels as well as percentages, so the sidebar never gets narrower than 150px and collapses to a 52px icon rail instead of disappearing.

Q4 roadmap review

Maya Chen · Today at 9:41

Hi team,

I pulled together the notes from Tuesday and a draft timeline. The short version: billing ships first, then the new onboarding, and search moves to January.

Could everyone leave comments by Thursday so we can lock it in on Friday?

Thanks,
Maya

<div class="small-scrollbar w-full max-w-4xl overflow-x-auto rounded-xl border border-black/10 shadow-xs dark:border-white/10">
  <%# Sizes can be pixels too: the sidebar never gets narrower than 150px and collapses to a 52px icon rail. %>
  <div data-controller="resize" class="flex h-[26rem] min-w-[640px] bg-white dark:bg-neutral-900">
    <%# Sidebar %>
    <nav data-resize-target="panel" data-resize-default-size="22" data-resize-min-size="150px" data-resize-max-size="32" data-resize-collapsible data-resize-collapsed-size="52px" style="flex: 22 1 0px" aria-label="Mailboxes" class="group/sidebar min-w-0 overflow-hidden bg-neutral-50 dark:bg-neutral-950/40">
      <ul class="px-2.5 py-2 text-sm">
        <li><a href="#" title="Inbox" aria-current="page" class="flex items-center gap-2.5 rounded-md border-y-2 border-transparent bg-clip-padding bg-black/5 px-2 py-1.5 font-medium text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 group-data-[collapsed]/sidebar:justify-center dark:bg-white/10 dark:text-white dark:focus-visible:outline-neutral-200">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.25 3.6 3.6a1.25 1.25 0 0 1 1.2-.85h6.4a1.25 1.25 0 0 1 1.2.85l1.85 5.65v2.75a1.25 1.25 0 0 1-1.25 1.25H3A1.25 1.25 0 0 1 1.75 12V9.25Z"/><path d="M1.75 9.25H5.5l.75 1.5h3.5l.75-1.5h3.75"/></svg>
          <span class="flex-1 truncate group-data-[collapsed]/sidebar:sr-only">Inbox</span><span class="text-xs tabular-nums text-neutral-400 group-data-[collapsed]/sidebar:hidden dark:text-neutral-500">12</span></a></li>
        <li><a href="#" title="Starred" class="flex items-center gap-2.5 rounded-md border-y-2 border-transparent bg-clip-padding px-2 py-1.5 text-neutral-600 hover:bg-black/5 hover:text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 group-data-[collapsed]/sidebar:justify-center dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:outline-neutral-200">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 1.75 9.9 5.9l4.35.5-3.2 3.05.85 4.3L8 11.6l-3.9 2.15.85-4.3-3.2-3.05 4.35-.5L8 1.75Z"/></svg>
          <span class="flex-1 truncate group-data-[collapsed]/sidebar:sr-only">Starred</span></a></li>
        <li><a href="#" title="Sent" class="flex items-center gap-2.5 rounded-md border-y-2 border-transparent bg-clip-padding px-2 py-1.5 text-neutral-600 hover:bg-black/5 hover:text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 group-data-[collapsed]/sidebar:justify-center dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:outline-neutral-200">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m14.25 1.75-6.5 6.5"/><path d="m14.25 1.75-4.25 12.5-2.25-6-6-2.25 12.5-4.25Z"/></svg>
          <span class="flex-1 truncate group-data-[collapsed]/sidebar:sr-only">Sent</span></a></li>
        <li><a href="#" title="Drafts" class="flex items-center gap-2.5 rounded-md border-y-2 border-transparent bg-clip-padding px-2 py-1.5 text-neutral-600 hover:bg-black/5 hover:text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 group-data-[collapsed]/sidebar:justify-center dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:outline-neutral-200">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 1.75H4.75a1 1 0 0 0-1 1v10.5a1 1 0 0 0 1 1h6.5a1 1 0 0 0 1-1V5L9 1.75Z"/><path d="M9 1.75V5h3.25"/></svg>
          <span class="flex-1 truncate group-data-[collapsed]/sidebar:sr-only">Drafts</span><span class="text-xs tabular-nums text-neutral-400 group-data-[collapsed]/sidebar:hidden dark:text-neutral-500">3</span></a></li>
        <li><a href="#" title="Archive" class="flex items-center gap-2.5 rounded-md border-y-2 border-transparent bg-clip-padding px-2 py-1.5 text-neutral-600 hover:bg-black/5 hover:text-neutral-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 group-data-[collapsed]/sidebar:justify-center dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:outline-neutral-200">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.75" width="12.5" height="3.5" rx="1"/><path d="M2.75 6.25v6a1 1 0 0 0 1 1h8.5a1 1 0 0 0 1-1v-6"/><path d="M6.5 9h3"/></svg>
          <span class="flex-1 truncate group-data-[collapsed]/sidebar:sr-only">Archive</span></a></li>
      </ul>
    </nav>

    <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize mailboxes"
         class="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-black/10 outline-none before:absolute before:inset-y-0 before:-left-px before:-right-px before:bg-neutral-400 before:opacity-0 before:transition-opacity before:duration-150 after:absolute after:inset-y-0 after:-left-1.5 after:-right-1.5 hover:before:opacity-100 hover:before:delay-150 focus-visible:before:delay-0 data-[dragging]:before:delay-0 focus-visible:before:bg-neutral-900 focus-visible:before:opacity-100 data-[dragging]:before:bg-neutral-900 data-[dragging]:before:opacity-100 pointer-coarse:after:-left-5 pointer-coarse:after:-right-5 dark:bg-white/10 dark:before:bg-neutral-500 dark:focus-visible:before:bg-white dark:data-[dragging]:before:bg-white"></div>

    <%# Message list %>
    <div data-resize-target="panel" data-resize-default-size="34" data-resize-min-size="190px" style="flex: 34 1 0px" class="flex min-w-0 flex-col overflow-hidden">
      <p class="shrink-0 border-b border-black/5 px-4 py-2.5 text-sm font-semibold text-neutral-900 dark:border-white/5 dark:text-white">Inbox</p>
      <ul class="small-scrollbar min-h-0 flex-1 divide-y divide-black/5 overflow-y-auto dark:divide-white/5">
        <li><a href="#" aria-current="true" class="block px-4 py-3 bg-neutral-50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:bg-white/5 dark:focus-visible:outline-neutral-200">
          <div class="flex items-baseline justify-between gap-2"><span class="truncate text-sm font-medium text-neutral-900 dark:text-white">Maya Chen</span><span class="shrink-0 text-xs tabular-nums text-neutral-400 dark:text-neutral-500">9:41</span></div>
          <p class="truncate text-sm text-neutral-700 dark:text-neutral-300">Q4 roadmap review</p>
          <p class="truncate text-xs text-neutral-500 dark:text-neutral-400">I pulled together the notes from Tuesday and a draft timeline.</p>
        </a></li>
        <li><a href="#" class="block px-4 py-3 hover:bg-neutral-50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-white/5 dark:focus-visible:outline-neutral-200">
          <div class="flex items-baseline justify-between gap-2"><span class="truncate text-sm font-medium text-neutral-900 dark:text-white">Stripe</span><span class="shrink-0 text-xs tabular-nums text-neutral-400 dark:text-neutral-500">8:15</span></div>
          <p class="truncate text-sm text-neutral-700 dark:text-neutral-300">Your payout is on its way</p>
          <p class="truncate text-xs text-neutral-500 dark:text-neutral-400">A payout of $4,210.00 should arrive in your account by Friday.</p>
        </a></li>
        <li><a href="#" class="block px-4 py-3 hover:bg-neutral-50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-white/5 dark:focus-visible:outline-neutral-200">
          <div class="flex items-baseline justify-between gap-2"><span class="truncate text-sm font-medium text-neutral-900 dark:text-white">Jonas Weber</span><span class="shrink-0 text-xs text-neutral-400 dark:text-neutral-500">Tue</span></div>
          <p class="truncate text-sm text-neutral-700 dark:text-neutral-300">Re: Onboarding copy</p>
          <p class="truncate text-xs text-neutral-500 dark:text-neutral-400">Love the second option. Can we shorten the headline a little?</p>
        </a></li>
        <li><a href="#" class="block px-4 py-3 hover:bg-neutral-50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:hover:bg-white/5 dark:focus-visible:outline-neutral-200">
          <div class="flex items-baseline justify-between gap-2"><span class="truncate text-sm font-medium text-neutral-900 dark:text-white">GitHub</span><span class="shrink-0 text-xs text-neutral-400 dark:text-neutral-500">Mon</span></div>
          <p class="truncate text-sm text-neutral-700 dark:text-neutral-300">[harbor] Fix flaky deploy spec (#482)</p>
          <p class="truncate text-xs text-neutral-500 dark:text-neutral-400">priya-n requested your review on this pull request.</p>
        </a></li>
      </ul>
    </div>

    <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize message list"
         class="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-black/10 outline-none before:absolute before:inset-y-0 before:-left-px before:-right-px before:bg-neutral-400 before:opacity-0 before:transition-opacity before:duration-150 after:absolute after:inset-y-0 after:-left-1.5 after:-right-1.5 hover:before:opacity-100 hover:before:delay-150 focus-visible:before:delay-0 data-[dragging]:before:delay-0 focus-visible:before:bg-neutral-900 focus-visible:before:opacity-100 data-[dragging]:before:bg-neutral-900 data-[dragging]:before:opacity-100 pointer-coarse:after:-left-5 pointer-coarse:after:-right-5 dark:bg-white/10 dark:before:bg-neutral-500 dark:focus-visible:before:bg-white dark:data-[dragging]:before:bg-white"></div>

    <%# Reading pane %>
    <article data-resize-target="panel" data-resize-min-size="30" style="flex: 44 1 0px" class="small-scrollbar min-w-0 overflow-y-auto">
      <div class="p-5">
      <h3 class="text-base font-semibold text-neutral-900 dark:text-white">Q4 roadmap review</h3>
      <p class="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">Maya Chen · Today at 9:41</p>
      <div class="mt-4 space-y-3 text-sm leading-6 text-neutral-600 dark:text-neutral-400">
        <p>Hi team,</p>
        <p>I pulled together the notes from Tuesday and a draft timeline. The short version: billing ships first, then the new onboarding, and search moves to January.</p>
        <p>Could everyone leave comments by Thursday so we can lock it in on Friday?</p>
        <p>Thanks,<br>Maya</p>
      </div>
      </div>
    </article>
  </div>
</div>

Resizable Preview

A single resizable element: the second panel is just empty space. The card inside uses container queries, so dragging the handle shows how it responds at different widths.

Cabin on Lake Ørsa

Sleeps 4 · Sauna · Free cancellation

$184 / night

<div class="w-full max-w-3xl">
  <%# One resizable element: the second panel is just empty space. The card uses container queries, so it responds to the frame instead of the window. %>
  <div data-controller="resize" class="flex h-72 w-full rounded-xl bg-neutral-100 bg-[repeating-linear-gradient(135deg,transparent_0_6px,rgb(0_0_0/0.04)_6px_7px)] dark:bg-neutral-950 dark:bg-[repeating-linear-gradient(135deg,transparent_0_6px,rgb(255_255_255/0.05)_6px_7px)]">
    <div data-resize-target="panel" data-resize-default-size="100" data-resize-min-size="230px" style="flex: 100 1 0px" class="@container min-w-0">
      <div class="flex h-full items-center justify-center overflow-hidden rounded-xl border border-black/10 bg-white p-4 shadow-xs dark:border-white/10 dark:bg-neutral-900">
      <div class="flex w-full max-w-md flex-col gap-4 @[18rem]:flex-row @[18rem]:items-center">
        <div class="flex aspect-[2/1] w-full shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-400 @[18rem]:aspect-square @[18rem]:w-28 dark:bg-neutral-800 dark:text-neutral-500">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 9.5 12 4l9 5.5v8.5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9.5Z"/><path d="M9 19v-6h6v6"/></svg>
        </div>
        <div class="min-w-0 flex-1">
          <p class="truncate text-sm font-semibold text-neutral-900 dark:text-white">Cabin on Lake Ørsa</p>
          <p class="mt-0.5 truncate text-xs text-neutral-500 dark:text-neutral-400">Sleeps 4 · Sauna · Free cancellation</p>
          <div class="mt-3 flex items-center justify-between gap-3">
            <p class="text-sm text-neutral-900 dark:text-white"><span class="font-semibold tabular-nums">$184</span> <span class="text-neutral-500 dark:text-neutral-400">/ night</span></p>
            <button type="button" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-3 py-1.5 text-xs font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">Book</button>
          </div>
        </div>
      </div>
      </div>
    </div>

    <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize preview"
         class="group/handle relative z-10 flex w-4 shrink-0 cursor-col-resize touch-none items-center justify-center outline-none after:absolute after:inset-y-0 after:-left-1 after:-right-1 pointer-coarse:after:-left-3 pointer-coarse:after:-right-3">
      <span class="h-8 w-1 rounded-full bg-neutral-300 transition-[height,background-color] duration-200 ease-out group-hover/handle:h-12 group-hover/handle:bg-neutral-400 group-focus-visible/handle:h-12 group-focus-visible/handle:bg-neutral-900 group-data-[dragging]/handle:h-16 group-data-[dragging]/handle:bg-neutral-900 motion-reduce:transition-none dark:bg-neutral-600 dark:group-hover/handle:bg-neutral-400 dark:group-focus-visible/handle:bg-white dark:group-data-[dragging]/handle:bg-white"></span>
    </div>

    <%# Empty space the preview can grow into %>
    <div data-resize-target="panel" style="flex: 0 1 0px" class="min-w-0" aria-hidden="true">
    </div>
  </div>
</div>

Collapsible Terminal

The last panel can be collapsible too. Drag the terminal down past half of its 120px minimum, press Enter on the handle, or use the button. The button gets aria-expanded automatically, so you can style it with aria-expanded:.

deploy.rb
service "harbor"
image   "acme/harbor"

servers do
  web  "10.0.0.11", "10.0.0.12"
  jobs "10.0.0.21"
end

healthcheck path: "/up", interval: 5

Terminal

$ bin/harbor deploy
Building acme/harbor:7f3c2a1… done (cached)
Pushing to 3 servers… done
Deployed in 41s, zero downtime
<div class="w-full max-w-3xl">
  <%# The last panel is collapsible: drag it down past half its minimum, press Enter on the handle, or use the button. %>
  <div data-controller="resize" data-resize-orientation-value="vertical" class="flex h-96 w-full flex-col overflow-hidden rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
    <%# Editor %>
    <div data-resize-target="panel" data-resize-default-size="68" data-resize-min-size="30" style="flex: 68 1 0px" class="flex min-h-0 flex-col overflow-hidden">
      <div class="flex shrink-0 items-center justify-between gap-3 border-b border-black/5 px-4 py-1.5 dark:border-white/5">
        <span class="truncate text-xs font-medium text-neutral-900 dark:text-white">deploy.rb</span>
        <button type="button" data-action="resize#toggle" data-resize-panel-param="resize-terminal" aria-controls="resize-terminal"
                class="flex shrink-0 items-center justify-center gap-1.5 rounded-lg bg-transparent px-2 py-1 text-xs font-medium whitespace-nowrap text-neutral-600 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 aria-expanded:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200 dark:aria-expanded:text-white">
          <svg xmlns="http://www.w3.org/2000/svg" class="size-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.75" width="12.5" height="10.5" rx="2"/><path d="m4.75 6.25 2 1.75-2 1.75"/><path d="M8.5 10h2.75"/></svg>
          Terminal
        </button>
      </div>
      <pre class="small-scrollbar min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-xs leading-5 text-neutral-700 dark:text-neutral-300"><span class="text-neutral-400 dark:text-neutral-500">service</span> "harbor"
<span class="text-neutral-400 dark:text-neutral-500">image</span>   "acme/harbor"

<span class="text-neutral-400 dark:text-neutral-500">servers</span> <span class="text-neutral-400 dark:text-neutral-500">do</span>
  web  "10.0.0.11", "10.0.0.12"
  jobs "10.0.0.21"
<span class="text-neutral-400 dark:text-neutral-500">end</span>

<span class="text-neutral-400 dark:text-neutral-500">healthcheck</span> path: "/up", interval: 5</pre>
    </div>

    <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize terminal"
         class="relative z-10 h-px w-full shrink-0 cursor-row-resize touch-none bg-black/10 outline-none before:absolute before:inset-x-0 before:-top-px before:-bottom-px before:bg-neutral-400 before:opacity-0 before:transition-opacity before:duration-150 after:absolute after:inset-x-0 after:-top-1.5 after:-bottom-1.5 hover:before:opacity-100 hover:before:delay-150 focus-visible:before:delay-0 data-[dragging]:before:delay-0 focus-visible:before:bg-neutral-900 focus-visible:before:opacity-100 data-[dragging]:before:bg-neutral-900 data-[dragging]:before:opacity-100 pointer-coarse:after:-top-5 pointer-coarse:after:-bottom-5 dark:bg-white/10 dark:before:bg-neutral-500 dark:focus-visible:before:bg-white dark:data-[dragging]:before:bg-white"></div>

    <%# Terminal %>
    <section id="resize-terminal" data-resize-target="panel" data-resize-min-size="120px" data-resize-collapsible style="flex: 32 1 0px" aria-label="Terminal" class="flex min-h-0 flex-col overflow-hidden bg-neutral-50 dark:bg-neutral-950/40">
      <%# A fixed minimum height keeps the content from reflowing while the panel slides shut. %>
      <div class="flex min-h-[120px] flex-1 flex-col">
        <p class="shrink-0 px-4 pb-1 pt-2.5 text-[11px] font-medium text-neutral-400 dark:text-neutral-500">Terminal</p>
        <pre class="small-scrollbar min-h-0 flex-1 overflow-auto px-4 pb-3 font-mono text-xs leading-5 text-neutral-600 dark:text-neutral-400">$ bin/harbor deploy
Building acme/harbor:7f3c2a1… done (cached)
Pushing to 3 servers… done
<span class="text-emerald-600 dark:text-emerald-400">Deployed in 41s, zero downtime</span></pre>
      </div>
    </section>
  </div>
</div>

Layout Presets

Buttons with data-action="resize#set" and data-resize-sizes-param="[15, 70, 15]" animate to a preset, one size per panel. Min and max sizes still apply.

Layout

Layers

30%

Canvas

40%

Properties

30%
<div class="w-full max-w-3xl">
  <%# resize#set animates to the given sizes, one per panel. The buttons live in the group, outside the flex container. %>
  <div data-controller="resize" class="overflow-hidden rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
    <div class="flex items-center gap-1 overflow-x-auto border-b border-black/10 px-3 py-2 dark:border-white/10">
      <span class="mr-auto pr-3 text-xs font-medium text-neutral-500 dark:text-neutral-400">Layout</span>
      <% [["Balanced", "[30, 40, 30]"], ["Focus", "[15, 70, 15]"], ["Inspect", "[20, 35, 45]"]].each do |label, sizes| %>
        <button type="button" data-action="resize#set" data-resize-sizes-param="<%= sizes %>"
                class="flex shrink-0 items-center justify-center rounded-lg bg-transparent px-2.5 py-1 text-xs font-medium whitespace-nowrap text-neutral-800 transition-all duration-100 ease-in-out select-none hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:text-neutral-50 dark:hover:bg-neutral-600/50 dark:hover:text-white dark:focus-visible:outline-neutral-200">
          <%= label %>
        </button>
      <% end %>
    </div>

    <div class="flex h-56">
      <% panels = [
        { name: "Layers", default: 30, min: 15 },
        { name: "Canvas", default: 40, min: 30 },
        { name: "Properties", default: 30, min: 15 }
      ] %>
      <% panels.each_with_index do |panel, index| %>
        <% if index.positive? %>
          <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize <%= panels[index - 1][:name].downcase %>"
               class="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-black/10 outline-none before:absolute before:inset-y-0 before:-left-px before:-right-px before:bg-neutral-400 before:opacity-0 before:transition-opacity before:duration-150 after:absolute after:inset-y-0 after:-left-1.5 after:-right-1.5 hover:before:opacity-100 hover:before:delay-150 focus-visible:before:delay-0 data-[dragging]:before:delay-0 focus-visible:before:bg-neutral-900 focus-visible:before:opacity-100 data-[dragging]:before:bg-neutral-900 data-[dragging]:before:opacity-100 pointer-coarse:after:-left-5 pointer-coarse:after:-right-5 dark:bg-white/10 dark:before:bg-neutral-500 dark:focus-visible:before:bg-white dark:data-[dragging]:before:bg-white"></div>
        <% end %>
        <div data-resize-target="panel" data-resize-default-size="<%= panel[:default] %>" data-resize-min-size="<%= panel[:min] %>" style="flex: <%= panel[:default] %> 1 0px"
             class="min-w-0 overflow-hidden <%= "bg-neutral-50 dark:bg-neutral-950/40" if index == 1 %>">
          <div class="flex h-full flex-col justify-between p-4">
            <p class="truncate text-sm font-medium text-neutral-900 dark:text-neutral-100"><%= panel[:name] %></p>
            <span data-resize-target="size" class="text-2xl font-semibold tabular-nums tracking-tight text-neutral-900 dark:text-white"><%= panel[:default] %>%</span>
          </div>
        </div>
      <% end %>
    </div>
  </div>
</div>

Hidden Until Opened

A group that starts hidden, in a closed <details>, a modal or an inactive tab, lays itself out the first time it becomes visible. Pixel sizes like the 220px default here resolve correctly. Open it to see.

Query plan
  1. Hash Join
  2. Seq Scan on orders
  3. Index Scan on customers
Node
Hash Join
Rows
4,812
Time
12.4 ms
Condition
orders.customer_id = customers.id
<div class="w-full max-w-3xl">
  <%# The group is hidden when the page loads (here inside <details>; the same goes for modals and tabs). It lays itself out the first time it becomes visible, pixel sizes included. %>
  <details class="group overflow-hidden rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900">
    <summary class="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 text-sm font-medium text-neutral-900 select-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 dark:text-white dark:focus-visible:outline-neutral-200 [&::-webkit-details-marker]:hidden">
      Query plan
      <svg xmlns="http://www.w3.org/2000/svg" class="size-4 shrink-0 text-neutral-500 transition-transform duration-200 group-open:rotate-180 motion-reduce:transition-none dark:text-neutral-400" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>
    </summary>

    <div data-controller="resize" class="flex h-56 border-t border-black/10 dark:border-white/10">
      <div data-resize-target="panel" data-resize-default-size="220px" data-resize-min-size="160px" data-resize-max-size="60" style="flex: 35 1 0px" class="min-w-0 overflow-hidden bg-neutral-50 dark:bg-neutral-950/40">
        <ol class="space-y-1 p-3 font-mono text-xs text-neutral-600 dark:text-neutral-400">
          <li class="truncate rounded-md bg-black/5 px-2 py-1.5 font-medium text-neutral-900 dark:bg-white/10 dark:text-white">Hash Join</li>
          <li class="truncate px-2 py-1.5 pl-5">Seq Scan on orders</li>
          <li class="truncate px-2 py-1.5 pl-5">Index Scan on customers</li>
        </ol>
      </div>

      <div data-resize-target="handle" role="separator" tabindex="0" aria-label="Resize plan steps"
           class="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-black/10 outline-none before:absolute before:inset-y-0 before:-left-px before:-right-px before:bg-neutral-400 before:opacity-0 before:transition-opacity before:duration-150 after:absolute after:inset-y-0 after:-left-1.5 after:-right-1.5 hover:before:opacity-100 hover:before:delay-150 focus-visible:before:delay-0 data-[dragging]:before:delay-0 focus-visible:before:bg-neutral-900 focus-visible:before:opacity-100 data-[dragging]:before:bg-neutral-900 data-[dragging]:before:opacity-100 pointer-coarse:after:-left-5 pointer-coarse:after:-right-5 dark:bg-white/10 dark:before:bg-neutral-500 dark:focus-visible:before:bg-white dark:data-[dragging]:before:bg-white"></div>

      <div data-resize-target="panel" data-resize-min-size="30" style="flex: 65 1 0px" class="min-w-0 overflow-hidden">
        <dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 p-4 text-xs">
          <dt class="text-neutral-500 dark:text-neutral-400">Node</dt><dd class="truncate font-medium text-neutral-900 dark:text-white">Hash Join</dd>
          <dt class="text-neutral-500 dark:text-neutral-400">Rows</dt><dd class="tabular-nums text-neutral-900 dark:text-white">4,812</dd>
          <dt class="text-neutral-500 dark:text-neutral-400">Time</dt><dd class="tabular-nums text-neutral-900 dark:text-white">12.4 ms</dd>
          <dt class="text-neutral-500 dark:text-neutral-400">Condition</dt><dd class="truncate font-mono text-neutral-900 dark:text-white">orders.customer_id = customers.id</dd>
        </dl>
      </div>
    </div>
  </details>
</div>

ViewComponent

Prefer Ruby? Resize::Component takes panels as slots and inserts the handles between them. It writes the inline sizes for you, raises a clear error for unknown options, and with storage: :cookie it renders the saved layout on the server, so a reload never jumps. Resize the demo below and reload the page. Apps without ViewComponent get the same options and markup from the shared/components/resize/resize partial, cookie layouts included. Both are in the ViewComponent and ERB Template buttons at the top of the page.

Overview

Drag the sidebar narrower and it collapses to an icon rail. Reload the page: the layout comes back before any JavaScript runs.

<div class="w-full max-w-3xl">
  <%# Saved in a cookie and rendered on the server: resize, reload, and the layout is there from the first paint. %>
  <%= render Resize::Component.new(storage_key: "resize:docs-demo", storage: :cookie,
                                   classes: "h-64 w-full rounded-xl border border-black/10 bg-white shadow-xs dark:border-white/10 dark:bg-neutral-900") do |group| %>
    <% group.with_panel(tag: :nav, default_size: 28, min_size: "150px", max_size: 45, collapsible: true, collapsed_size: "52px",
                        classes: "group/sidebar bg-neutral-50 dark:bg-neutral-950/40", handle_label: "Resize navigation",
                        html: { aria: { label: "Projects" } }) do %>
      <ul class="space-y-px p-2 text-sm">
        <% [["Overview", true], ["Deploys", false], ["Settings", false]].each do |name, current| %>
          <li>
            <a href="#" title="<%= name %>" <%= "aria-current=page" if current %>
               class="flex items-center gap-2.5 rounded-md px-2 py-1.5 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-neutral-600 group-data-[collapsed]/sidebar:justify-center dark:focus-visible:outline-neutral-200 <%= current ? "bg-black/5 font-medium text-neutral-900 dark:bg-white/10 dark:text-white" : "text-neutral-600 hover:bg-black/5 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/5 dark:hover:text-white" %>">
              <span class="flex size-4 shrink-0 items-center justify-center rounded bg-neutral-200 text-[10px] font-semibold text-neutral-600 dark:bg-neutral-700 dark:text-neutral-300" aria-hidden="true"><%= name.first %></span>
              <span class="truncate group-data-[collapsed]/sidebar:sr-only"><%= name %></span>
            </a>
          </li>
        <% end %>
      </ul>
    <% end %>
    <% group.with_panel(tag: :main, min_size: 40) do %>
      <div class="space-y-2 p-5">
        <p class="text-sm font-semibold text-neutral-900 dark:text-white">Overview</p>
        <p class="text-sm leading-6 text-neutral-600 dark:text-neutral-400">Drag the sidebar narrower and it collapses to an icon rail. Reload the page: the layout comes back before any JavaScript runs.</p>
      </div>
    <% end %>
  <% end %>
</div>

Configuration

Controller Values

Set these on the element with data-controller="resize".

Data attribute Default Description
data-resize-orientation-value "horizontal" Use "horizontal" for panels side by side or "vertical" for stacked panels.
data-resize-storage-key-value none Key used to save and restore the layout. Use a key that is unique to this layout. A saved layout is ignored automatically once you change the panels' size options.
data-resize-storage-value "local" Where the layout is saved: "local" for localStorage (kept in sync across tabs), or "cookie" for a cookie named after the storage key, which Rails can read to render the saved sizes on the server.
data-resize-keyboard-step-value 5 Percent the handle moves per arrow key press. Holding Shift triples it.

Panel Options

Set these on each data-resize-target="panel". Sizes are percentages of the group (30) or pixels (240px). Panels are layout boxes, so put padding and borders on an element inside the panel. Add a matching inline style="flex: 30 1 0px" so the layout is right before JavaScript loads.

Data attribute Default Description
data-resize-default-size equal share Initial size. Panels without one split the remaining space.
data-resize-min-size 0 Smallest size the panel can be resized to.
data-resize-max-size 100 Largest size the panel can be resized to.
data-resize-collapsible off Lets the panel snap closed when dragged past half of its minimum size. The panel gets a data-collapsed attribute you can style against.
data-resize-collapsed-size 0 Size of the panel while collapsed, for example 56px for an icon rail.

Targets, Actions and Events

Name Type Description
panel Target A resizable panel. Panels and handles alternate inside one flex container.
handle Target The draggable separator between two panels. Gets data-dragging while it is being dragged; the group gets data-resizing.
size Target Optional element inside a panel whose text is kept in sync with the panel's size, like 40%.
resize#toggle Action Collapses or expands a collapsible panel. Pass data-resize-panel-param with the panel's id or index; without it the first collapsible panel is used. resize#collapse and resize#expand do one or the other. The button gets aria-controls and aria-expanded automatically.
resize#reset Action Animates back to the default sizes and clears the saved layout.
resize#set Action Animates to the given sizes, one per panel: data-resize-sizes-param="[30, 70]". Pixel strings like "240px" work too. From JavaScript, call setSizes([30, 70]) on the controller.
resize:change Event Dispatched on every frame while the layout changes, with event.detail.sizes (percentages), event.detail.collapsed (booleans) and event.detail.panels (panel ids). Use it for live updates.
resize:commit Event Dispatched once when an interaction finishes: a drag is released, a key is pressed, a panel is toggled, or the layout is reset or set. Same detail as resize:change. Use it to save the layout to your server.

Keyboard and Pointer

Input Result
Arrow keys Move the focused handle by one step. Hold Shift for a larger step.
Home / End Move the handle as far as it can go in either direction.
Enter Collapse or expand the collapsible panel next to the handle.
Escape While dragging, cancel and return to the sizes from before the drag.
Double-click / double-tap Reset the group to its default sizes.

Saving Layouts

Three ways to remember a layout. Each saved layout carries a fingerprint of the panels' size options, so it is dropped automatically once you change defaults, limits or collapsibility.

  • localStorage: add data-resize-storage-key-value="editor:layout". Restored when the controller connects and kept in sync across tabs.
  • Cookie: also add data-resize-storage-value="cookie". Resize::Component reads it and renders the saved sizes, so the first paint is already right. In plain ERB, read JSON.parse(cookies["editor_layout"]) (characters other than letters, digits, _ and - in the key become _) and write the sizes into the inline styles.
  • Your backend: listen for resize:commit, which fires once per finished interaction, and save the sizes with the user.
// app/javascript/controllers/layout_controller.js
// <div data-controller="resize layout" data-action="resize:commit->layout#save" data-layout-url-value="/preferences/layout">
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = { url: String }

  save({ detail: { sizes, collapsed } }) {
    fetch(this.urlValue, {
      method: "PATCH",
      headers: { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content },
      body: JSON.stringify({ sizes, collapsed })
    })
  }
}

Styling Hooks

Attribute Set on Use it like
data-dragging Handle data-[dragging]:bg-neutral-900, or group-data-[dragging]/handle: on elements inside the handle.
data-resizing Group Present while any handle in the group is dragged, e.g. to dim an iframe preview.
data-collapsed Panel Add group/sidebar to the panel and use group-data-[collapsed]/sidebar:sr-only on labels for an icon rail. [[data-collapsed]+&]:bg-transparent on a handle hides its line next to a closed panel.
aria-expanded Toggle button Kept in sync by the controller. Style the button with aria-expanded:.

Accessibility

  • Handles get role="separator", tabindex="0", aria-orientation, aria-valuenow, aria-valuemin, aria-valuemax and aria-controls. Panels without an id get one. You only add an aria-label that says what the handle resizes.
  • Buttons that collapse a panel get aria-controls and aria-expanded. Fully collapsed panels become inert, so their links leave the tab order.
  • In an icon rail, hide labels with sr-only, not hidden, so links keep their names, and add a title for a tooltip.
  • Touch screens get larger hit areas through pointer-coarse:, and every animation respects reduced motion.
  • Setup mistakes (a wrong number of handles, padding on panels, a missing label…) are reported in the browser console with the element to fix.

Browser Support

Current Chrome, Edge, Firefox and Safari, on desktop and mobile, in left-to-right and right-to-left pages. inert needs Chrome 102, Firefox 112 or Safari 15.5; older browsers still resize, but collapsed panels keep their links in the tab order. The handle classes use the pointer-coarse: variant from Tailwind CSS 4.1.

Table of contents

Powered by

Get notified when new components come out