Resizable panel group with pointer-drag and keyboard separators, N panels, min/max constraints, and collapsible panels.

Preview
Style
Horizontal
Sidebar
Content
Vertical
Header
Content
With Handle
Sidebar
Content
Nested
One
Two
Three
Controlled
30%
70%
Sizes come from the LayoutChanged out-message the parent owns.
Collapsible
Sidebar
Content
The sidebar starts at 25% with a 15% minimum. Drag it under the minimum to snap it shut, or focus the handle and press Enter.
src/demo/views/resizable.ts427 lines
import { Update } from 'foldkit'
import { Match as M, Option, Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Subscription } from 'foldkit'

import * as resizable from '../../generated/registry/ui/resizable'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  GotResizableHorizontalMessage: { message: resizable.Message },
  GotResizableVerticalMessage: { message: resizable.Message },
  GotResizableWithHandleMessage: { message: resizable.Message },
  GotResizableNestedOuterMessage: { message: resizable.Message },
  GotResizableNestedInnerMessage: { message: resizable.Message },
  GotResizableControlledMessage: { message: resizable.Message },
  GotResizableCollapsibleMessage: { message: resizable.Message },
})

const panelLabel = (text: string, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex h-full items-center justify-center p-6')],
    [h.span([h.Class('font-semibold')], [text])],
  )

const percentagePanel = (value: number, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex h-full flex-col items-center justify-center gap-2 p-6')],
    [h.span([h.Class('font-semibold')], [`${String(Math.round(value))}%`])],
  )

const group = (
  model: resizable.Model,
  viewInputs: resizable.ViewInputs,
  toParentMessage: (message: resizable.Message) => AppMessage,
  h: HtmlBuilder<AppMessage>,
): Html =>
  h.submodel({
    slotId: model.id,
    model,
    view: resizable.view,
    viewInputs,
    toParentMessage,
  })

export const resizableView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Horizontal']),
          group(
            model.resizableHorizontal,
            {
              className: 'min-h-[200px] rounded-lg border',
              panels: [{}, {}],
              handles: [{}],
              handleLabel: 'Resize sidebar and content',
              toPanelContent: (index) =>
                index === 0 ? panelLabel('Sidebar', h) : panelLabel('Content', h),
            },
            (message) => Message.GotResizableHorizontalMessage({ message }),
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Vertical']),
          group(
            model.resizableVertical,
            {
              className: 'h-[240px] rounded-lg border',
              panels: [{}, {}],
              handles: [{}],
              handleLabel: 'Resize header and content',
              toPanelContent: (index) =>
                index === 0 ? panelLabel('Header', h) : panelLabel('Content', h),
            },
            (message) => Message.GotResizableVerticalMessage({ message }),
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Handle']),
          group(
            model.resizableWithHandle,
            {
              className: 'min-h-[200px] rounded-lg border',
              panels: [{}, {}],
              handles: [{ withHandle: true }],
              handleLabel: 'Resize sidebar and content',
              toPanelContent: (index) =>
                index === 0 ? panelLabel('Sidebar', h) : panelLabel('Content', h),
            },
            (message) => Message.GotResizableWithHandleMessage({ message }),
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Nested']),
          group(
            model.resizableNestedOuter,
            {
              className: 'min-h-[240px] rounded-lg border',
              panels: [{}, {}],
              handles: [{}],
              handleLabel: 'Resize One and the nested group',
              toPanelContent: (index) =>
                index === 0
                  ? panelLabel('One', h)
                  : group(
                      model.resizableNestedInner,
                      {
                        panels: [{}, {}],
                        handles: [{}],
                        handleLabel: 'Resize Two and Three',
                        toPanelContent: (innerIndex) =>
                          innerIndex === 0 ? panelLabel('Two', h) : panelLabel('Three', h),
                      },
                      (message) => Message.GotResizableNestedInnerMessage({ message }),
                      h,
                    ),
            },
            (message) => Message.GotResizableNestedOuterMessage({ message }),
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Controlled']),
          group(
            model.resizableControlled,
            {
              className: 'min-h-[200px] rounded-lg border',
              panels: [{}, {}],
              handles: [{}],
              handleLabel: 'Resize the controlled panels',
              toPanelContent: (index) =>
                index === 0
                  ? percentagePanel(model.resizableControlledLayout.left ?? 30, h)
                  : percentagePanel(model.resizableControlledLayout.right ?? 70, h),
            },
            (message) => Message.GotResizableControlledMessage({ message }),
            h,
          ),
          h.div(
            [h.Class('px-1 text-xs text-muted-foreground')],
            ['Sizes come from the LayoutChanged out-message the parent owns.'],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Collapsible']),
          group(
            model.resizableCollapsible,
            {
              className: 'min-h-[200px] rounded-lg border',
              panels: [{}, {}],
              handles: [{}],
              handleLabel: 'Resize the collapsible sidebar',
              toPanelContent: (index) =>
                index === 0 ? panelLabel('Sidebar', h) : panelLabel('Content', h),
            },
            (message) => Message.GotResizableCollapsibleMessage({ message }),
            h,
          ),
          h.div(
            [h.Class('px-1 text-xs text-muted-foreground')],
            [
              'The sidebar starts at 25% with a 15% minimum. Drag it under the minimum to snap it shut, or focus the handle and press Enter.',
            ],
          ),
        ],
      ),
    ],
  )

const noopFold =
  <Out>(): ((out: Out) => Update.Step<State, unknown>) =>
  () =>
  (model) => ({ model })

const foldOtherOutMessage = M.type<resizable.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    LayoutChanged: noopFold(),
  }),
)

const foldControlledOutMessage = M.type<resizable.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    LayoutChanged:
      ({ layout }) =>
      (model) => ({ model: evo(model, { resizableControlledLayout: () => layout }) }),
  }),
)

const foldResizableHorizontal = Update.foldChild({
  update: resizable.update,
  read: (model: State) => Option.some(model.resizableHorizontal),
  write: (model, next) => evo(model, { resizableHorizontal: () => next }),
  toParentMessage: (message) => Message.GotResizableHorizontalMessage({ message }),
  foldOutMessage: foldOtherOutMessage,
})

const foldResizableVertical = Update.foldChild({
  update: resizable.update,
  read: (model: State) => Option.some(model.resizableVertical),
  write: (model, next) => evo(model, { resizableVertical: () => next }),
  toParentMessage: (message) => Message.GotResizableVerticalMessage({ message }),
  foldOutMessage: foldOtherOutMessage,
})

const foldResizableWithHandle = Update.foldChild({
  update: resizable.update,
  read: (model: State) => Option.some(model.resizableWithHandle),
  write: (model, next) => evo(model, { resizableWithHandle: () => next }),
  toParentMessage: (message) => Message.GotResizableWithHandleMessage({ message }),
  foldOutMessage: foldOtherOutMessage,
})

const foldResizableNestedOuter = Update.foldChild({
  update: resizable.update,
  read: (model: State) => Option.some(model.resizableNestedOuter),
  write: (model, next) => evo(model, { resizableNestedOuter: () => next }),
  toParentMessage: (message) => Message.GotResizableNestedOuterMessage({ message }),
  foldOutMessage: foldOtherOutMessage,
})

const foldResizableNestedInner = Update.foldChild({
  update: resizable.update,
  read: (model: State) => Option.some(model.resizableNestedInner),
  write: (model, next) => evo(model, { resizableNestedInner: () => next }),
  toParentMessage: (message) => Message.GotResizableNestedInnerMessage({ message }),
  foldOutMessage: foldOtherOutMessage,
})

const foldResizableControlled = Update.foldChild({
  update: resizable.update,
  read: (model: State) => Option.some(model.resizableControlled),
  write: (model, next) => evo(model, { resizableControlled: () => next }),
  toParentMessage: (message) => Message.GotResizableControlledMessage({ message }),
  foldOutMessage: foldControlledOutMessage,
})

const foldResizableCollapsible = Update.foldChild({
  update: resizable.update,
  read: (model: State) => Option.some(model.resizableCollapsible),
  write: (model, next) => evo(model, { resizableCollapsible: () => next }),
  toParentMessage: (message) => Message.GotResizableCollapsibleMessage({ message }),
  foldOutMessage: foldOtherOutMessage,
})

const fields = {
  resizableHorizontal: resizable.Model,
  resizableVertical: resizable.Model,
  resizableWithHandle: resizable.Model,
  resizableNestedOuter: resizable.Model,
  resizableNestedInner: resizable.Model,
  resizableControlled: resizable.Model,
  resizableControlledLayout: S.Record(S.String, S.Number),
  resizableCollapsible: resizable.Model,
}

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

type ResizableAppMessage =
  | typeof Message.GotResizableHorizontalMessage.Type
  | typeof Message.GotResizableVerticalMessage.Type
  | typeof Message.GotResizableWithHandleMessage.Type
  | typeof Message.GotResizableNestedOuterMessage.Type
  | typeof Message.GotResizableNestedInnerMessage.Type
  | typeof Message.GotResizableControlledMessage.Type
  | typeof Message.GotResizableCollapsibleMessage.Type

const liftDragPointer = (
  name: string,
  read: (model: State) => resizable.Model,
  toParentMessage: (message: resizable.Message) => ResizableAppMessage,
) => {
  const lifted = Subscription.lift({
    dragPointer: resizable.subscriptions.dragPointer,
  })<State, ResizableAppMessage>({
    toChildModel: read,
    toParentMessage,
  })
  return { [`${name}DragPointer`]: lifted.dragPointer }
}

export const subscriptions = Subscription.aggregate<State, ResizableAppMessage>()(
  liftDragPointer(
    'resizableHorizontal',
    (model) => model.resizableHorizontal,
    (message) => Message.GotResizableHorizontalMessage({ message }),
  ),
  liftDragPointer(
    'resizableVertical',
    (model) => model.resizableVertical,
    (message) => Message.GotResizableVerticalMessage({ message }),
  ),
  liftDragPointer(
    'resizableWithHandle',
    (model) => model.resizableWithHandle,
    (message) => Message.GotResizableWithHandleMessage({ message }),
  ),
  liftDragPointer(
    'resizableNestedOuter',
    (model) => model.resizableNestedOuter,
    (message) => Message.GotResizableNestedOuterMessage({ message }),
  ),
  liftDragPointer(
    'resizableNestedInner',
    (model) => model.resizableNestedInner,
    (message) => Message.GotResizableNestedInnerMessage({ message }),
  ),
  liftDragPointer(
    'resizableControlled',
    (model) => model.resizableControlled,
    (message) => Message.GotResizableControlledMessage({ message }),
  ),
  liftDragPointer(
    'resizableCollapsible',
    (model) => model.resizableCollapsible,
    (message) => Message.GotResizableCollapsibleMessage({ message }),
  ),
)

export const slice = defineSlice({
  fields,
  init: {
    resizableHorizontal: resizable.init({
      id: 'resizable-demo-horizontal',
      panels: [{ id: 'sidebar', defaultSize: 25 }, { id: 'content' }],
    }),
    resizableVertical: resizable.init({
      id: 'resizable-demo-vertical',
      orientation: 'vertical',
      panels: [{ id: 'header', defaultSize: 25 }, { id: 'content' }],
    }),
    resizableWithHandle: resizable.init({
      id: 'resizable-demo-with-handle',
      panels: [{ id: 'sidebar', defaultSize: 25 }, { id: 'content' }],
    }),
    resizableNestedOuter: resizable.init({
      id: 'resizable-demo-nested-outer',
      panels: [{ id: 'one' }, { id: 'nested' }],
    }),
    resizableNestedInner: resizable.init({
      id: 'resizable-demo-nested-inner',
      orientation: 'vertical',
      panels: [{ id: 'two', defaultSize: 25 }, { id: 'three' }],
    }),
    resizableControlled: resizable.init({
      id: 'resizable-demo-controlled',
      panels: [
        { id: 'left', defaultSize: 30, minSize: 20 },
        { id: 'right', minSize: 30 },
      ],
    }),
    resizableControlledLayout: { left: 30, right: 70 },
    resizableCollapsible: resizable.init({
      id: 'resizable-demo-collapsible',
      panels: [
        { id: 'sidebar', defaultSize: 25, minSize: 15, collapsible: true, collapsedSize: 0 },
        { id: 'content' },
      ],
    }),
  },
  messages: [
    Message.GotResizableHorizontalMessage,
    Message.GotResizableVerticalMessage,
    Message.GotResizableWithHandleMessage,
    Message.GotResizableNestedOuterMessage,
    Message.GotResizableNestedInnerMessage,
    Message.GotResizableControlledMessage,
    Message.GotResizableCollapsibleMessage,
  ],
  handlers: (model: State) => ({
    GotResizableHorizontalMessage: (
      payload: typeof Message.GotResizableHorizontalMessage.Type,
    ): UpdateReturn => foldResizableHorizontal(model, payload.message),
    GotResizableVerticalMessage: (
      payload: typeof Message.GotResizableVerticalMessage.Type,
    ): UpdateReturn => foldResizableVertical(model, payload.message),
    GotResizableWithHandleMessage: (
      payload: typeof Message.GotResizableWithHandleMessage.Type,
    ): UpdateReturn => foldResizableWithHandle(model, payload.message),
    GotResizableNestedOuterMessage: (
      payload: typeof Message.GotResizableNestedOuterMessage.Type,
    ): UpdateReturn => foldResizableNestedOuter(model, payload.message),
    GotResizableNestedInnerMessage: (
      payload: typeof Message.GotResizableNestedInnerMessage.Type,
    ): UpdateReturn => foldResizableNestedInner(model, payload.message),
    GotResizableControlledMessage: (
      payload: typeof Message.GotResizableControlledMessage.Type,
    ): UpdateReturn => foldResizableControlled(model, payload.message),
    GotResizableCollapsibleMessage: (
      payload: typeof Message.GotResizableCollapsibleMessage.Type,
    ): UpdateReturn => foldResizableCollapsible(model, payload.message),
  }),
  samples: [
    Message.GotResizableHorizontalMessage({
      message: resizable.Message.KeyedHandle({ handleIndex: 0, key: 'ArrowRight' }),
    }),
  ],
  subscriptions,
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/resizable

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/resizable.ts210 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update/subscriptions into your app:
 *  `import * as Resizable from '@/components/ui/resizable'`
 */
import { Option } from 'effect'
import type { Html } from 'foldkit/html'
import { defineView } from 'foldkit/submodel'

import * as ResizableBox from '@/ui/resizable-box'
import { cn } from '@/lib/utils'

// Resizable renders the styled shadcn parts (PanelGroup, Panel, Handle) over the
// ResizableBox engine. Wire it like any Submodel: `init` with the panel
// constraints, embed with `h.submodel`, lift `subscriptions`, and listen for
// `LayoutChanged` to own the split. The handle is a real pointer-drag,
// keyboard-operable separator with min/max and collapsible support.

export const Model = ResizableBox.Model
export type Model = typeof Model.Type
export const Message = ResizableBox.Message
export type Message = typeof Message.Type
export const OutMessage = ResizableBox.OutMessage
export type OutMessage = typeof OutMessage.Type

export const init = ResizableBox.init
export const update = ResizableBox.update
export const reflect = ResizableBox.reflect
export const subscriptions = ResizableBox.subscriptions
export const MeasureContainer = ResizableBox.MeasureContainer
export const layoutFromModel = ResizableBox.layoutFromModel

export type InitConfig = ResizableBox.InitConfig
export type PanelConfig = ResizableBox.PanelConfig
export type Layout = ResizableBox.Layout

/** Upstream ResizablePanelGroup string. */
export const resizablePanelGroupClass =
  'flex h-full w-full aria-[orientation=vertical]:flex-col'

/** Upstream ResizablePanel carries no class of its own. */
export const resizablePanelClass = ''

/** Upstream ResizableHandle string (the emitted aria-orientation drives the
 *  perpendicular variants). */
export const resizableHandleClass =
  'relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90'

/** Upstream ResizableHandle `withHandle` grip. */
export const resizableHandleIconClass = 'bg-border h-6 w-1 rounded-lg z-10 flex shrink-0'

const LEFT_MOUSE_BUTTON = 0

const RESIZE_KEYS: ReadonlySet<string> = new Set([
  'ArrowLeft',
  'ArrowRight',
  'ArrowUp',
  'ArrowDown',
  'Home',
  'End',
  'Enter',
])

export type ResizablePanelInput = Readonly<{
  className?: string
}>

export type ResizableHandleInput = Readonly<{
  /** Renders the centered grip (upstream `withHandle`). */
  withHandle?: boolean
  className?: string
}>

export type ViewInputs = Readonly<{
  /** One entry per panel, in the same order the panels were declared in
   *  `init`. */
  panels: ReadonlyArray<ResizablePanelInput>
  /** One entry per separator (panels minus one). */
  handles?: ReadonlyArray<ResizableHandleInput>
  /** Slot callback building the panel at `index`. Runs in the embedding
   *  boundary, so a panel may contain a nested Submodel. */
  toPanelContent: (index: number) => Html
  className?: string
  /** Accessible name for every separator. Defaults to none. */
  handleLabel?: string
}>

// The panel is the flex item that grows by its percentage; the inner wrapper
// carries the caller's class and scrolls, matching react-resizable-panels'
// own two-node structure so `h-full` content sizes predictably.
const panelStyle = (size: number): Record<string, string> => {
  // oxlint-disable-next-line anti-slop/no-known-value-widening -- SAFETY: style bag must be Record<string,string> for h.Style; literal evidence is intentionally widened to the style contract
  return {
    display: 'flex',
    'flex-basis': '0',
    'flex-grow': String(size),
    'flex-shrink': '1',
    'min-width': '0',
    'min-height': '0',
    overflow: 'visible',
  }
}

const panelContentStyle = (horizontal: boolean): Record<string, string> => {
  // oxlint-disable-next-line anti-slop/no-known-value-widening -- SAFETY: style bag must be Record<string,string> for h.Style; literal evidence is intentionally widened to the style contract
  return {
    'max-height': '100%',
    'max-width': '100%',
    'flex-grow': '1',
    overflow: 'auto',
    'touch-action': horizontal ? 'pan-y' : 'pan-x',
  }
}

const handleStyle = (horizontal: boolean): Record<string, string> => {
  // oxlint-disable-next-line anti-slop/no-known-value-widening -- SAFETY: style bag must be Record<string,string> for h.Style; literal evidence is intentionally widened to the style contract
  return {
    'flex-basis': 'auto',
    'flex-grow': '0',
    'flex-shrink': '0',
    'touch-action': 'none',
    cursor: horizontal ? 'col-resize' : 'row-resize',
  }
}

// Upstream's Group sets orientation through flex-direction, not an
// `aria-orientation` on a role-less div; the `aria-[orientation=vertical]`
// utility in the copied class string is left in place to stay byte-identical
// to upstream.
const groupStyle = (horizontal: boolean): Record<string, string> => {
  // oxlint-disable-next-line anti-slop/no-known-value-widening -- SAFETY: style bag must be Record<string,string> for h.Style; literal evidence is intentionally widened to the style contract
  return {
    'flex-direction': horizontal ? 'row' : 'column',
    overflow: 'hidden',
    'touch-action': horizontal ? 'pan-y' : 'pan-x',
  }
}

/** Renders the styled panel group with a separator between every adjacent
 *  pair. Embedded via `h.submodel`. */
export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) => {
  const horizontal = model.orientation === 'horizontal'
  const separatorOrientation = horizontal ? 'vertical' : 'horizontal'
  const lastIndex = model.panels.length - 1

  const panel = (index: number): Html => {
    const state = model.panels[index]
    const input = viewInputs.panels[index]
    return h.div(
      [
        h.Id(ResizableBox.panelDomId(model.id, state?.id ?? '')),
        h.DataAttribute('slot', 'resizable-panel'),
        h.Style(panelStyle(state?.size ?? 0)),
      ],
      [
        h.div(
          [
            h.Class(cn(resizablePanelClass, input?.className)),
            h.Style(panelContentStyle(horizontal)),
          ],
          [viewInputs.toPanelContent(index)],
        ),
      ],
    )
  }

  const separator = (index: number): Html => {
    const aria = ResizableBox.separatorAria(model, index)
    const input = viewInputs.handles?.[index]
    return h.div(
      [
        h.Role('separator'),
        h.Tabindex(0),
        h.AriaOrientation(separatorOrientation),
        h.AriaControls(aria.valueControls),
        h.AriaValuemin(aria.valueMin),
        h.AriaValuemax(aria.valueMax),
        h.AriaValuenow(aria.valueNow),
        ...(viewInputs.handleLabel === undefined ? [] : [h.AriaLabel(viewInputs.handleLabel)]),
        h.DataAttribute('slot', 'resizable-handle'),
        h.Style(handleStyle(horizontal)),
        h.OnPointerDown((_pointerType, button, _screenX, _screenY, _timeStamp, clientX, clientY) =>
          button === LEFT_MOUSE_BUTTON
            ? Option.some(Message.PressedHandle({ handleIndex: index, clientX, clientY }))
            : Option.none(),
        ),
        h.OnKeyDownPreventDefault((key) =>
          RESIZE_KEYS.has(key)
            ? Option.some(Message.KeyedHandle({ handleIndex: index, key }))
            : Option.none(),
        ),
        h.OnDoubleClick(Message.DoubleClickedHandle({ handleIndex: index })),
        h.Class(cn(resizableHandleClass, input?.className)),
      ],
      input?.withHandle === true ? [h.div([h.Class(resizableHandleIconClass)], [])] : [],
    )
  }

  return h.div(
    [
      h.Id(model.id),
      h.Class(cn(resizablePanelGroupClass, viewInputs.className)),
      h.DataAttribute('slot', 'resizable-panel-group'),
      h.Style(groupStyle(horizontal)),
    ],
    model.panels.flatMap((_panel, index) =>
      index === lastIndex ? [panel(index)] : [panel(index), separator(index)],
    ),
  )
})