Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Reorder

Drag list rows by a handle to reorder them.

Drag list rows into a new order by their grip handle. Pressing the handle lifts the row, its siblings slide to preview the drop slot, and the list auto-scrolls when you drag near its edges. On release the component emits { from, to } — you apply the move to your own array (the “auto” completion model, contrast Ionic’s manual complete()) and let it re-render. Dragging starts only from the handle, so vertical list scrolling stays natural.

Installation

npx touchcn add reorder

Usage

<tcn-reorder (reorder)="onReorder($event)">
  @for (item of items(); track item) {
    <tcn-reorder-item>
      <div class="row">{{ item }}</div>
      <tcn-reorder-handle />
    </tcn-reorder-item>
  }
</tcn-reorder>
import { TcnReorder, TcnReorderItem, TcnReorderHandle } from '@/components/ui/reorder';

<TcnReorder onReorder={({ from, to }) => setItems((items) => move(items, from, to))}>
  {items.map((item) => (
    <TcnReorderItem key={item}>
      <div className="row">{item}</div>
      <TcnReorderHandle />
    </TcnReorderItem>
  ))}
</TcnReorder>
<script setup lang="ts">
import { TcnReorder, TcnReorderItem, TcnReorderHandle } from '@/components/ui/reorder';
</script>

<template>
  <TcnReorder @reorder="({ from, to }) => (items = move(items, from, to))">
    <TcnReorderItem v-for="item in items" :key="item">
      <div class="row">{{ item }}</div>
      <TcnReorderHandle />
    </TcnReorderItem>
  </TcnReorder>
</template>

Apply the emitted move to your own data, for example:

function move<T>(items: T[], from: number, to: number): T[] {
  const next = [...items];
  const [moved] = next.splice(from, 1);
  next.splice(to, 0, moved);
  return next;
}

All gesture behaviour — pointer tracking, the translateY writes, hit-testing, sibling shifting, auto-scroll and the keyboard reorder — lives in the engine (TcnReorderDirective / useReorder); the copied components own only markup. Long-press-anywhere to drag is out of scope (dragging is handle-only by design); a future engine addition can layer it on without changing this markup.

Accessibility

Each handle is a focusable role="button". The keyboard reorder follows the WAI listbox-reorder pattern at a baseline depth: Space/Enter toggles pick-up (reflected via aria-pressed), the arrow keys move the grabbed row one slot at a time and focus follows it, and Esc cancels. Screen-reader announcements are limited to the handle’s aria-pressed state — richer live-region narration is not yet implemented.

Props

Reorder

Prop Type Default Description
reorder / onReorder (e: { from, to }) => void Fired on commit; apply the move to your own array.
disabled boolean false Disable both the drag and keyboard reorder.

Reorder Handle

Prop Type Default Description
label string 'Drag to reorder' Accessible label for the grip.
Source · Angular
export * from './tcn-reorder';
import { Component, input } from '@angular/core';
import { TcnReorderDirective } from '@touchcn/angular/reorder';

export type { TcnReorderEvent } from '@touchcn/angular/reorder';

/**
 * Drag-to-reorder list group. Wraps a set of `tcn-reorder-item` rows and, via
 * the host `TcnReorderDirective`, lets each row be dragged from its
 * `tcn-reorder-handle` grip to a new position — lifting the pressed row,
 * shifting its siblings to preview the drop slot and auto-scrolling near the
 * edges. On release it emits `reorder` with `{ from, to }`; apply the move to
 * your own array (the "auto" model) and let it re-render.
 *
 * All gesture behaviour — pointer tracking, the `translateY` writes, hit-testing,
 * auto-scroll and the keyboard reorder — lives in the engine directive; this
 * component owns only markup. Drag starts from the handle only, so list
 * scrolling stays natural; long-press-anywhere is out of scope (a future engine
 * addition can layer it on without changing this markup).
 */
@Component({
  selector: 'tcn-reorder',
  hostDirectives: [{ directive: TcnReorderDirective, inputs: ['disabled'], outputs: ['reorder'] }],
  template: '<ng-content />',
  host: { class: 'tcn-reorder block', role: 'list' },
})
export class TcnReorder {}

/** A single reorderable row. Place a `tcn-reorder-handle` inside as the drag grip. */
@Component({
  selector: 'tcn-reorder-item',
  template: '<ng-content />',
  host: { class: 'tcn-reorder-item', role: 'listitem' },
})
export class TcnReorderItem {}

/**
 * The drag grip for a `tcn-reorder-item` — a focusable trailing handle showing a
 * platform grip icon (iOS lines / MD3 dots). Dragging starts only from here.
 * Focus it and press Space/Enter to pick the row up, then Arrow Up/Down to move
 * it and Space/Enter (or Escape to cancel) to drop.
 */
@Component({
  selector: 'tcn-reorder-handle',
  template: `
    <svg class="if-ios" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M4 8h16M4 16h16" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
    </svg>
    <svg class="if-md" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <circle cx="9" cy="6" r="1.4" fill="currentColor" />
      <circle cx="15" cy="6" r="1.4" fill="currentColor" />
      <circle cx="9" cy="12" r="1.4" fill="currentColor" />
      <circle cx="15" cy="12" r="1.4" fill="currentColor" />
      <circle cx="9" cy="18" r="1.4" fill="currentColor" />
      <circle cx="15" cy="18" r="1.4" fill="currentColor" />
    </svg>
  `,
  host: {
    class: 'tcn-reorder-handle',
    tabindex: '0',
    role: 'button',
    'aria-pressed': 'false',
    '[attr.aria-label]': 'label()',
  },
})
export class TcnReorderHandle {
  readonly label = input('Drag to reorder');
}
Source · React
export * from './tcn-reorder';
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { cn } from '@touchcn/core';
import { useReorder } from '@touchcn/react';
import type { TcnReorderEvent } from '@touchcn/react';

export type { TcnReorderEvent } from '@touchcn/react';

export interface TcnReorderProps {
  /** Apply the move to your own array, then let it re-render (the "auto" model). */
  onReorder: (event: TcnReorderEvent) => void;
  /** Disables the gesture and the keyboard alternative entirely. */
  disabled?: boolean;
  children?: ReactNode;
  className?: string;
}

/**
 * Drag-to-reorder list group, mirroring the Angular component. Wraps a set of
 * `TcnReorderItem` rows and, via the engine `useReorder` hook, lets each row be
 * dragged from its `TcnReorderHandle` grip to a new position — lifting the
 * pressed row, shifting siblings to preview the drop slot and auto-scrolling
 * near the edges. On release it calls `onReorder` with `{ from, to }`; apply the
 * move to your own array (the "auto" model) and let React re-render.
 *
 * Emits the same `tcn-*` classes and `data-*` attributes as the Angular
 * component. Drag starts from the handle only, so list scrolling stays natural;
 * long-press-anywhere is out of scope.
 */
export function TcnReorder({ onReorder, disabled, children, className }: TcnReorderProps) {
  const { groupRef, dragging } = useReorder({ onReorder, disabled });

  return (
    <div ref={groupRef} role="list" className={cn('tcn-reorder block', className)} data-dragging={dragging || undefined}>
      {children}
    </div>
  );
}

export interface TcnReorderItemProps extends ComponentPropsWithoutRef<'div'> {
  children?: ReactNode;
}

/** A single reorderable row. Place a `TcnReorderHandle` inside as the drag grip. */
export function TcnReorderItem({ className, children, ...props }: TcnReorderItemProps) {
  return (
    <div role="listitem" className={cn('tcn-reorder-item', className)} {...props}>
      {children}
    </div>
  );
}

export interface TcnReorderHandleProps extends ComponentPropsWithoutRef<'div'> {
  /** Accessible label for the grip handle. */
  label?: string;
}

/**
 * The drag grip for a `TcnReorderItem` — a focusable trailing handle showing a
 * platform grip icon (iOS lines / MD3 dots). Dragging starts only from here.
 * Focus it and press Space/Enter to pick the row up, then Arrow Up/Down to move
 * it and Space/Enter (or Escape to cancel) to drop.
 */
export function TcnReorderHandle({ label = 'Drag to reorder', className, ...props }: TcnReorderHandleProps) {
  return (
    <div
      role="button"
      tabIndex={0}
      aria-pressed={false}
      aria-label={label}
      className={cn('tcn-reorder-handle', className)}
      {...props}
    >
      <svg className="if-ios" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path d="M4 8h16M4 16h16" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
      </svg>
      <svg className="if-md" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <circle cx="9" cy="6" r="1.4" fill="currentColor" />
        <circle cx="15" cy="6" r="1.4" fill="currentColor" />
        <circle cx="9" cy="12" r="1.4" fill="currentColor" />
        <circle cx="15" cy="12" r="1.4" fill="currentColor" />
        <circle cx="9" cy="18" r="1.4" fill="currentColor" />
        <circle cx="15" cy="18" r="1.4" fill="currentColor" />
      </svg>
    </div>
  );
}
Source · Vue
<script setup lang="ts">
import { useReorder } from '@touchcn/vue';
import type { TcnReorderEvent } from '@touchcn/vue';

/**
 * Drag-to-reorder list group, mirroring the Angular / React component. Wraps a
 * set of `TcnReorderItem` rows and, via the engine `useReorder` composable, lets
 * each row be dragged from its `TcnReorderHandle` grip to a new position —
 * lifting the pressed row, shifting siblings to preview the drop slot and
 * auto-scrolling near the edges. On release it emits `reorder` with
 * `{ from, to }`; apply the move to your own array (the "auto" model) and let Vue
 * re-render.
 *
 * Emits the same `tcn-*` classes and `data-*` attributes across frameworks. Drag
 * starts from the handle only, so list scrolling stays natural; long-press-
 * anywhere is out of scope.
 */
const props = withDefaults(defineProps<{ disabled?: boolean }>(), { disabled: false });

const emit = defineEmits<{ reorder: [event: TcnReorderEvent] }>();

const { setGroup, dragging } = useReorder({
  onReorder: (event) => emit('reorder', event),
  disabled: () => props.disabled,
});
</script>

<template>
  <div :ref="setGroup" role="list" class="tcn-reorder block" :data-dragging="dragging || undefined">
    <slot />
  </div>
</template>
<script setup lang="ts">
/**
 * The drag grip for a `TcnReorderItem` — a focusable trailing handle showing a
 * platform grip icon (iOS lines / MD3 dots). Dragging starts only from here.
 * Focus it and press Space/Enter to pick the row up, then Arrow Up/Down to move
 * it and Space/Enter (or Escape to cancel) to drop.
 */
withDefaults(defineProps<{ label?: string }>(), { label: 'Drag to reorder' });
</script>

<template>
  <div role="button" :tabindex="0" :aria-pressed="false" :aria-label="label" class="tcn-reorder-handle">
    <svg class="if-ios" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M4 8h16M4 16h16" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
    </svg>
    <svg class="if-md" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <circle cx="9" cy="6" r="1.4" fill="currentColor" />
      <circle cx="15" cy="6" r="1.4" fill="currentColor" />
      <circle cx="9" cy="12" r="1.4" fill="currentColor" />
      <circle cx="15" cy="12" r="1.4" fill="currentColor" />
      <circle cx="9" cy="18" r="1.4" fill="currentColor" />
      <circle cx="15" cy="18" r="1.4" fill="currentColor" />
    </svg>
  </div>
</template>
<script setup lang="ts">
/** A single reorderable row. Place a `TcnReorderHandle` inside as the drag grip. */
</script>

<template>
  <div role="listitem" class="tcn-reorder-item"><slot /></div>
</template>
export { default as TcnReorder } from './TcnReorder.vue';
export { default as TcnReorderItem } from './TcnReorderItem.vue';
export { default as TcnReorderHandle } from './TcnReorderHandle.vue';
export type { TcnReorderEvent } from '@touchcn/vue';

Last updated on July 24, 2026

Was this page helpful?