Skip to main content
Nexus Design System
GitHub

Resizable

Resizable splits an area into panels that users resize by dragging or with the keyboard. Use it for layouts like a sidebar beside an editor. Give every ResizableHandle an aria-label.

One
Two
'use client';

import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from '@/components/nxds/components/resizable/resizable';

const groupClass =
  'nx:max-w-md nx:overflow-hidden nx:rounded-lg nx:border-default nx:border-border-default';

const groupStyle = { height: 220 } as const;

const panelStyle = { overflow: 'hidden' } as const;

function PanelBody({ label }: { label: string }) {
  return <div className="nx:p-6 nx:text-center nx:font-semibold">{label}</div>;
}

export default function ResizableDefault() {
  return (
    <ResizablePanelGroup
      orientation="horizontal"
      className={groupClass}
      style={groupStyle}
    >
      <ResizablePanel defaultSize={50} style={panelStyle}>
        <PanelBody label="One" />
      </ResizablePanel>
      <ResizableHandle withHandle aria-label="Resize panels" />
      <ResizablePanel defaultSize={50} style={panelStyle}>
        <PanelBody label="Two" />
      </ResizablePanel>
    </ResizablePanelGroup>
  );
}

Installation

npx nxds-cli@latest add resizable

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from '@/components/nxds/components/resizable/resizable';

Composition

ResizablePanelGroup
├── ResizablePanel
└── ResizableHandle

Examples

Vertical

Stack panels vertically, with a horizontal divider between them.

Top
Bottom
'use client';

import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from '@/components/nxds/components/resizable/resizable';

const groupClass =
  'nx:max-w-md nx:overflow-hidden nx:rounded-lg nx:border-default nx:border-border-default';

const groupStyle = { height: 220 } as const;

const panelStyle = { overflow: 'hidden' } as const;

function PanelBody({ label }: { label: string }) {
  return <div className="nx:p-6 nx:text-center nx:font-semibold">{label}</div>;
}

export default function ResizableVertical() {
  return (
    <ResizablePanelGroup
      orientation="vertical"
      className={groupClass}
      style={groupStyle}
    >
      <ResizablePanel defaultSize={50} style={panelStyle}>
        <PanelBody label="Top" />
      </ResizablePanel>
      <ResizableHandle withHandle aria-label="Resize panels" />
      <ResizablePanel defaultSize={50} style={panelStyle}>
        <PanelBody label="Bottom" />
      </ResizablePanel>
    </ResizablePanelGroup>
  );
}

Three panels

Split the area into three panels with two handles.

Nav
Content
Aside
'use client';

import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from '@/components/nxds/components/resizable/resizable';

const groupClass =
  'nx:max-w-md nx:overflow-hidden nx:rounded-lg nx:border-default nx:border-border-default';

const groupStyle = { height: 220 } as const;

const panelStyle = { overflow: 'hidden' } as const;

function PanelBody({ label }: { label: string }) {
  return <div className="nx:p-6 nx:text-center nx:font-semibold">{label}</div>;
}

export default function ResizableThreePanels() {
  return (
    <ResizablePanelGroup
      orientation="horizontal"
      className={groupClass}
      style={groupStyle}
    >
      <ResizablePanel defaultSize={25} style={panelStyle}>
        <PanelBody label="Nav" />
      </ResizablePanel>
      <ResizableHandle aria-label="Resize nav" />
      <ResizablePanel defaultSize={50} style={panelStyle}>
        <PanelBody label="Content" />
      </ResizablePanel>
      <ResizableHandle aria-label="Resize aside" />
      <ResizablePanel defaultSize={25} style={panelStyle}>
        <PanelBody label="Aside" />
      </ResizablePanel>
    </ResizablePanelGroup>
  );
}

Props

ResizableHandle

ResizableHandle props
PropTypeDefaultDescription
withHandlebooleanNo default documented

Shows a grip in the centre of the handle.

ResizablePanel

ResizablePanel props
PropTypeDefaultDescription
collapsedSizenumberNo default documented

Size when collapsed, in sizeUnit.

defaultSizenumberNo default documented

Starting size, in sizeUnit.

maxSizenumberNo default documented

Largest size, in sizeUnit.

minSizenumber0

Smallest size, in sizeUnit.

sizeUnit"px" | "%""%"

Unit for every size prop: a percentage of the group, or pixels.

ResizablePanelGroup

ResizablePanelGroup props
PropTypeDefaultDescription
onLayoutChange((layout: number[]) => void)No default documented

Called with each panel's size, as a percentage, while the layout changes.

orientation"horizontal" | "vertical""horizontal"

The axis the panels split along.

Resizable splits an area into panels that users resize by dragging or with the keyboard. Use it for layouts like a sidebar beside an editor. Give every ResizableHandle an aria-label.

<script setup lang="ts">
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from '@/components/resizable';
</script>

<template>
  <ResizablePanelGroup
    orientation="horizontal"
    class="nx:max-w-md nx:overflow-hidden nx:rounded-lg nx:border-default nx:border-border-default"
    style="height: 220px"
  >
    <ResizablePanel :default-size="50">
      <div class="nx:p-6 nx:text-center nx:font-semibold">One</div>
    </ResizablePanel>
    <ResizableHandle with-handle aria-label="Resize panels" />
    <ResizablePanel :default-size="50">
      <div class="nx:p-6 nx:text-center nx:font-semibold">Two</div>
    </ResizablePanel>
  </ResizablePanelGroup>
</template>

Installation

  1. Install the following dependencies:

    npm install @phosphor-icons/vue@^2.2.1 @vueuse/core@^14.1.0 reka-ui@^2.11.0
    
  2. Copy and paste the following code into your project.

    components/resizable/ResizableHandle.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      injectSplitterGroupContext,
      SplitterResizeHandle,
      type SplitterResizeHandleEmits,
      type SplitterResizeHandleProps,
      useForwardPropsEmits,
    } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { IconGripVertical } from '../../lib/icons';
    import { cn } from '../../lib/utils';
    
    export interface ResizableHandleProps extends SplitterResizeHandleProps {
      /**
       * Shows a grip in the centre of the handle.
       */
      withHandle?: boolean;
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ResizableHandleProps>();
    const emits = defineEmits<SplitterResizeHandleEmits>();
    
    const delegated = reactiveOmit(props, 'class', 'withHandle');
    const forwarded = useForwardPropsEmits(delegated, emits);
    
    // The divider runs across the split, as react-resizable-panels marks it; Reka
    // sets no aria-orientation.
    const group = injectSplitterGroupContext();
    </script>
    
    <template>
      <SplitterResizeHandle
        v-bind="forwarded"
        data-slot="resizable-handle"
        :aria-orientation="
          group.direction.value === 'horizontal' ? 'vertical' : 'horizontal'
        "
        :class="
          cn(
            'nx:relative nx:flex nx:w-px nx:items-center nx:justify-center nx:bg-border-default',
            'nx:after:absolute nx:after:inset-y-0 nx:after:left-1/2 nx:after:w-1 nx:after:-translate-x-1/2',
            'nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default',
            'nx:aria-[orientation=horizontal]:h-px nx:aria-[orientation=horizontal]:w-full nx:aria-[orientation=horizontal]:after:left-0 nx:aria-[orientation=horizontal]:after:h-1 nx:aria-[orientation=horizontal]:after:w-full nx:aria-[orientation=horizontal]:after:translate-x-0 nx:aria-[orientation=horizontal]:after:-translate-y-1/2',
            'nx:aria-[orientation=horizontal]:[&>div]:rotate-90',
            props.class
          )
        "
      >
        <div
          v-if="withHandle"
          class="nx:z-10 nx:flex nx:h-4 nx:w-3 nx:items-center nx:justify-center nx:rounded-sm nx:border-default nx:border-border-default nx:bg-border-default"
        >
          <IconGripVertical class="nx:size-2.5" />
        </div>
      </SplitterResizeHandle>
    </template>
    
    components/resizable/ResizablePanel.vue
    <script setup lang="ts">
    import {
      SplitterPanel,
      type SplitterPanelEmits,
      type SplitterPanelProps,
      useForwardPropsEmits,
    } from 'reka-ui';
    
    export interface ResizablePanelProps extends Omit<
      SplitterPanelProps,
      'defaultSize' | 'minSize' | 'maxSize' | 'collapsedSize' | 'sizeUnit'
    > {
      /**
       * Starting size, in `sizeUnit`.
       */
      defaultSize?: number;
      /**
       * Smallest size, in `sizeUnit`.
       */
      minSize?: number;
      /**
       * Largest size, in `sizeUnit`.
       */
      maxSize?: number;
      /**
       * Size when collapsed, in `sizeUnit`.
       */
      collapsedSize?: number;
      /**
       * Unit for every size prop: a percentage of the group, or pixels.
       */
      sizeUnit?: '%' | 'px';
    }
    
    const props = withDefaults(defineProps<ResizablePanelProps>(), {
      minSize: 0,
      sizeUnit: '%',
    });
    const emits = defineEmits<SplitterPanelEmits>();
    
    const forwarded = useForwardPropsEmits(props, emits);
    </script>
    
    <template>
      <SplitterPanel
        v-slot="slotProps"
        v-bind="forwarded"
        data-slot="resizable-panel"
      >
        <slot v-bind="slotProps" />
      </SplitterPanel>
    </template>
    
    components/resizable/ResizablePanelGroup.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import {
      SplitterGroup,
      type SplitterGroupProps,
      useForwardProps,
    } from 'reka-ui';
    import { defineComponent, type PropType, watch } from 'vue';
    
    export interface ResizablePanelGroupProps extends Omit<
      SplitterGroupProps,
      'direction'
    > {
      /**
       * The axis the panels split along.
       */
      orientation?: 'horizontal' | 'vertical';
    }
    
    const props = withDefaults(defineProps<ResizablePanelGroupProps>(), {
      orientation: 'horizontal',
    });
    const emits = defineEmits<{
      /**
       * Called with each panel's size, as a percentage, while the layout changes.
       */
      'layout-change': [layout: number[]];
    }>();
    
    const delegated = reactiveOmit(props, 'orientation');
    const forwarded = useForwardProps(delegated);
    
    // Reka's `layout` event reports `px` panels in pixels. Its slot's layout is
    // always percentages, so the emit watches that instead.
    const LayoutWatcher = defineComponent({
      props: { layout: { type: Array as PropType<number[]>, required: true } },
      setup(watcherProps) {
        watch(
          () => watcherProps.layout,
          (layout) => emits('layout-change', layout)
        );
        return () => null;
      },
    });
    </script>
    
    <template>
      <SplitterGroup
        v-slot="slotProps"
        v-bind="forwarded"
        :direction="orientation"
        data-slot="resizable-panel-group"
      >
        <slot v-bind="slotProps" />
        <LayoutWatcher :layout="slotProps.layout" />
      </SplitterGroup>
    </template>
    
    components/resizable/index.ts
    export {
      default as ResizableHandle,
      type ResizableHandleProps,
    } from './ResizableHandle.vue';
    export {
      default as ResizablePanel,
      type ResizablePanelProps,
    } from './ResizablePanel.vue';
    export {
      default as ResizablePanelGroup,
      type ResizablePanelGroupProps,
    } from './ResizablePanelGroup.vue';
    
    lib/icons.ts
    /**
     * Internal Icons
     *
     * This barrel file re-exports icons used internally by the design system.
     * These are hardcoded icons for component states (loading, validation, navigation).
     *
     * User-facing icons should be passed via props by consumers.
     *
     * This set draws Phosphor. `nxds init --icons` installs the same names drawn
     * by another library.
     */
    import { PhCircle } from '@phosphor-icons/vue';
    import { type FunctionalComponent, h } from 'vue';
    
    // Loading states
    export { PhCircleNotch as IconLoader2 } from '@phosphor-icons/vue';
    
    // Navigation
    export {
      PhArrowDown as IconArrowDown,
      PhArrowLeft as IconArrowLeft,
      PhArrowRight as IconArrowRight,
      PhArrowUp as IconArrowUp,
      PhCaretDown as IconChevronDown,
      PhCaretLeft as IconChevronLeft,
      PhCaretRight as IconChevronRight,
      PhCaretUp as IconChevronUp,
      PhDotsThree as IconDots,
      PhSidebarSimple as IconLayoutSidebar,
      PhCaretUpDown as IconSelector,
    } from '@phosphor-icons/vue';
    
    // Actions
    export {
      PhCheck as IconCheck,
      PhCopy as IconCopy,
      PhMinus as IconMinus,
      PhPencilSimple as IconPencil,
      PhPlus as IconPlus,
      PhX as IconX,
    } from '@phosphor-icons/vue';
    
    export const IconCircleFilled: FunctionalComponent = (_props, { attrs }) =>
      h(PhCircle, { weight: 'fill', ...attrs });
    
    // Filter field types
    export {
      PhCalendarBlank as IconCalendar,
      PhHash as IconHash,
      PhTextAa as IconLetterCase,
      PhListBullets as IconList,
    } from '@phosphor-icons/vue';
    
    // Search / input
    export { PhMagnifyingGlass as IconSearch } from '@phosphor-icons/vue';
    
    // Alerts/Status
    export {
      PhWarningCircle as IconAlertCircle,
      PhWarning as IconAlertTriangle,
      PhCheckCircle as IconCircleCheck,
      PhInfo as IconInfoCircle,
    } from '@phosphor-icons/vue';
    
    // Layout
    export {
      PhDotsSixVertical as IconGripVertical,
      PhPalette as IconPalette,
    } from '@phosphor-icons/vue';
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/resizable';

Examples

Vertical

Stack panels vertically, with a horizontal divider between them.

<script setup lang="ts">
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from '@/components/resizable';
</script>

<template>
  <ResizablePanelGroup
    orientation="vertical"
    class="nx:max-w-md nx:overflow-hidden nx:rounded-lg nx:border-default nx:border-border-default"
    style="height: 220px"
  >
    <ResizablePanel :default-size="50">
      <div class="nx:p-6 nx:text-center nx:font-semibold">Top</div>
    </ResizablePanel>
    <ResizableHandle with-handle aria-label="Resize panels" />
    <ResizablePanel :default-size="50">
      <div class="nx:p-6 nx:text-center nx:font-semibold">Bottom</div>
    </ResizablePanel>
  </ResizablePanelGroup>
</template>

Three panels

Split the area into three panels with two handles.

<script setup lang="ts">
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from '@/components/resizable';
</script>

<template>
  <ResizablePanelGroup
    orientation="horizontal"
    class="nx:max-w-md nx:overflow-hidden nx:rounded-lg nx:border-default nx:border-border-default"
    style="height: 220px"
  >
    <ResizablePanel :default-size="25">
      <div class="nx:p-6 nx:text-center nx:font-semibold">Nav</div>
    </ResizablePanel>
    <ResizableHandle aria-label="Resize nav" />
    <ResizablePanel :default-size="50">
      <div class="nx:p-6 nx:text-center nx:font-semibold">Content</div>
    </ResizablePanel>
    <ResizableHandle aria-label="Resize aside" />
    <ResizablePanel :default-size="25">
      <div class="nx:p-6 nx:text-center nx:font-semibold">Aside</div>
    </ResizablePanel>
  </ResizablePanelGroup>
</template>

Props

ResizableHandle

ResizableHandle props
PropTypeDefaultDescription
withHandlebooleanNo default documented

Shows a grip in the centre of the handle.

ResizablePanel

ResizablePanel props
PropTypeDefaultDescription
collapsedSizenumberNo default documented

Size when collapsed, in sizeUnit.

defaultSizenumberNo default documented

Starting size, in sizeUnit.

maxSizenumberNo default documented

Largest size, in sizeUnit.

minSizenumber0

Smallest size, in sizeUnit.

sizeUnit"%" | "px""%"

Unit for every size prop: a percentage of the group, or pixels.

ResizablePanelGroup

ResizablePanelGroup props
PropTypeDefaultDescription
orientation"vertical" | "horizontal""horizontal"

The axis the panels split along.