`Sortable` is a headless drag-to-reorder list primitive for ReactLynx. It performs reordering on the main thread for smooth, gesture-driven animations and exposes a composable `SortableRoot` / `SortableItem` / `SortableItemArea` API. For massive datasets, prefer `List` or `FeedList`; `Sortable` targets small to medium reorder-able lists.
Scanned 9/3/2026
Install to Claude Code
npx -y skills add lynx-family/lynx-ui --skill lynx-ui-sortable --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Lynx Ui Sortable?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/lynx-family-lynx-ui-sortable)More formats (shields.io, HTML) on the badges page.
# lynx-ui-sortable SKILL
`Sortable` is a headless drag-to-reorder list primitive for ReactLynx. It performs reordering on the main thread for smooth, gesture-driven animations and exposes a composable `SortableRoot` / `SortableItem` / `SortableItemArea` API. For massive datasets, prefer `List` or `FeedList`; `Sortable` targets small to medium reorder-able lists.
## 1. Core Capabilities
- **Headless Composition**: Three building blocks — `SortableRoot`, `SortableItem`, and (optional) `SortableItemArea` — let you keep full control of the visual layer.
- **Two Drag Surfaces**: Each `SortableItem` can act as `as='Draggable'` (whole row is the drag surface, default) or `as='DraggableRoot'` (only the inner `SortableItemArea` starts a drag).
- **Owned ScrollView**: Set `as='ScrollView'` on `SortableRoot` to render an internal `<scroll-view>` boundary; pair with `scrollableBoundaryId`, `scrollableStickyUpperOffset`, and `scrollableStickyLowerOffset` to get auto-scroll while dragging near edges.
- **External Boundary**: Use `boundaryId` to point at any element in the page tree as the drop boundary — drags exiting that area are cancelled.
- **Disabled Items (locked rows)**: `<SortableItem disabled>` produces a row that cannot be dragged, cannot be displaced, and **always keeps its absolute position** in the committed order. Other items can freely cross over it; only the relative order of non-disabled items can change.
- **Lifecycle Callbacks**: `onSortStart` and `onSortEnd(sortedData)` on the root. `sortedData` reflects the new order of `SortableData<T>` items, with disabled items pinned at their original indices.
- **Imperative Toggle**: `enableSorting` on the root (`true` by default) globally turns sorting on/off without unmounting items.
## 2. AI Coding Guide
### Minimal Usable Example
`SortableRoot` accepts a render-prop child, **not** static children. Each row is a `SortableItem` whose `sortingKey` MUST match `SortableData.getSortingKey()`.
```tsx
import { useState } from '@lynx-js/react'
import {
SortableItem,
SortableItemArea,
SortableRoot,
type SortableData,
} from '@lynx-js/lynx-ui'
interface Row { id: string; title: string }
function BasicSortable() {
const [data, setData] = useState<SortableData<Row>[]>(() =>
Array.from({ length: 6 }, (_, i) => {
const id = String(i)
return {
dataItem: { id, title: `Row ${i + 1}` },
getSortingKey: () => id,
}
})
)
return (
<SortableRoot
data={data}
onSortEnd={setData}
as='ScrollView'
scrollableClassName='sortable-scroll'
scrollableContentClassName='sortable-content'
>
{(item) => (
<SortableItem
key={item.getSortingKey()}
sortingKey={item.getSortingKey()}
as='DraggableRoot'
className='sortable-row'
>
<view className='sortable-row-surface'>
<text>{item.dataItem.title}</text>
<SortableItemArea className='sortable-handle'>
<text>⋮</text>
</SortableItemArea>
</view>
</SortableItem>
)}
</SortableRoot>
)
}
```
### Recommended Prompt Formula
> **Scenario Description** + **Drag Surface (whole row vs handle)** + **Boundary / ScrollView Setup** + **Locked Rows** + **Reorder Callback**
**Example Prompts:**
- "Build a sortable list of 10 cards inside a 360px tall scroll-view. Drag from a small handle on the right of each card; commit reorder via `onSortEnd`."
- "Create a sortable settings list where the first and last rows are locked and never move; other rows can be reordered between them and across them."
- "Render a sortable list with a custom external boundary — only allow dragging within a specific container, and cancel sorting if dragged outside."
## 3. Use Cases & Best Practices
### Whole-row drag surface (default)
```tsx
<SortableItem
key={item.getSortingKey()}
sortingKey={item.getSortingKey()}
>
<MyRowSurface data={item.dataItem} />
</SortableItem>
```
Touching anywhere on the row starts a drag. Best for tile / card layouts.
**Example Path**: `apps/examples/Sortable/Basic/index.tsx`
### Handle-only drag surface
```tsx
<SortableItem
key={item.getSortingKey()}
sortingKey={item.getSortingKey()}
as='DraggableRoot'
>
<view className='row'>
<view className='row-content'>{/* tap content */}</view>
<SortableItemArea className='row-handle'>
<text>⋮</text>
</SortableItemArea>
</view>
</SortableItem>
```
Required when row content has its own gestures (taps, switches). Only `SortableItemArea` starts a drag.
**Example Path**: `apps/examples/Sortable/Basic/index.tsx`
### Owned `ScrollView` with auto-scroll near edges
```tsx
<SortableRoot
as='ScrollView'
scrollableBoundaryId='mySortableScroll'
scrollableClassName='short-scroll-view'
scrollableContentClassName='sortable-root short-sortable-root'
scrollableStickyUpperOffset={12}
scrollableStickyLowerOffset={12}
data={data}
onSortEnd={setData}
>
{renderRow}
</SortableRoot>
```
When the dragged row reaches `12px` from the upper / lower edge, the internal `scroll-view` calls `scrollBy` so the list keeps moving with the drag. Call `lynx.querySelector('#mySortableScroll')?.invoke('scrollBy', { offset })` to scroll programmatically.
**Example Path**: `apps/examples/Sortable/ShortScrollView/index.tsx`
### External boundary (no owned ScrollView)
```tsx
<view id='myBoundary' className='boundary'>
<SortableRoot data={data} onSortEnd={setData} boundaryId='myBoundary'>
{renderRow}
</SortableRoot>
</view>
```
Drags exiting `#myBoundary` are cancelled. Use this when the sortable list is embedded inside an existing scroll container or modal that you control outside `Sortable`.
**Example Path**: `apps/examples/Sortable/NoBoundary/index.tsx`
### Locked rows that never move (`disabled`)
```tsx
const LOCKED = new Set(['0', '5', '7', '11'])
<SortableRoot data={data} onSortEnd={setData} as='ScrollView'>
{(item) => {
const isLocked = LOCKED.has(item.getSortingKey())
return (
<SortableItem
key={item.getSortingKey()}
sortingKey={item.getSortingKey()}
as='DraggableRoot'
disabled={isLocked}
>
<view className={isLocked ? 'row row--locked' : 'row'}>
<text>{item.dataItem.title}</text>
{isLocked
? <view className='row-area row-area--locked'><text>Locked</text></view>
: <SortableItemArea className='row-area'><text>⋮</text></SortableItemArea>}
</view>
</SortableItem>
)
}}
</SortableRoot>
```
Behavior of a `disabled` row:
- **Cannot be dragged itself.** No touch / longpress is bound; tapping it never starts a sort.
- **Never displaced** by any other row's drag. It always stays at its current screen position.
- **Position preserved in `onSortEnd`.** Its index in the committed `sortedData` is identical to its index in the input `data`. Only the relative order of non-disabled rows can change.
- **Cross-over allowed.** A non-disabled row dragged over a disabled row will skip past it (the disabled row does not move, the dragged row keeps moving), and the swap target on the far side animates with a higher speed so it ends exactly at the dragged row's original slot, with no visual overlap or jump.
Use this for fixed headers/footers, sticky default actions, or rows whose absolute position is part of the design.
**Example Path**: `apps/examples/Sortable/DisableItems/index.tsx`
### Disable sorting globally
```tsx
<SortableRoot data={data} onSortEnd={setData} enableSorting={readOnly === false}>
{renderRow}
</SortableRoot>
```
`enableSorting={false}` instantly removes drag bindings from every non-disabled item without unmounting. Useful for read-only modes.
**Example Path**: `apps/examples/Sortable/DisableSorting/index.tsx`
## 4. FAQ
**Q: Why is `children` a function in `SortableRoot`?**
A: `Sortable` needs to associate each rendered node with an internal `sortingKey` and ref map. The render-prop signature `(item: SortableData<T>) => ReactNode` makes that wiring explicit and lets `SortableRoot` re-render only the rows whose data identity actually changed.
**Q: Where is the source of truth for the order?**
A: Your application state is. `SortableRoot` is controlled by `data` and reports the committed order through `onSortEnd(sortedData)`. You typically `setData(sortedData)` in the handler. While dragging, only main-thread transforms move; the React `data` does not change until release.
**Q: My handle (`SortableItemArea`) does not start a drag.**
A: Confirm `as='DraggableRoot'` on the surrounding `SortableItem`. With the default `as='Draggable'`, the whole row is the drag surface and `SortableItemArea` is treated as plain content.
**Q: Disabled rows still appear to move during drag.**
A: They should not. If you see this, check that the `disabled` prop is actually `true` for those rows on every render (a stable `Set` via `useMemo` is recommended), and that the row's `sortingKey` is unique and stable across renders. Disabled items are never displaced, never picked as a swap target, and always keep their absolute index in `onSortEnd`.
**Q: How do I make the dragged row visually float above its neighbors?**
A: `Sortable` already raises the dragged row's `z-index` to `10000` while dragging and resets it after release; no extra styling is required.
**Q: Auto-scroll does not happen when I drag near the edges.**
A: Auto-scroll requires either `as='ScrollView'` on `SortableRoot` (so it owns the scroll-view) or a `scrollableBoundaryId` pointing at an existing scrollable element. Plain `as` (default) renders a `view` with no scrolling, so there is nothing to auto-scroll.
**Q: I see a wrong reorder commit when I drag back over a disabled gap and release.**
A: Make sure you are on a version that includes the disabled-aware confirmed-swap reset. If you can still reproduce the case where releasing inside a disabled gap commits a stale swap, see `apps/examples/Sortable/DisableItemsStaleSwap/index.tsx` for the canonical repro and file an issue.
## 5. Sub components
- **`SortableRoot`**: Renders the boundary (plain `view` or owned `<scroll-view>`), owns drag state, and receives `data` / `onSortEnd`.
- **`SortableItem`**: Renders one row. Required props: `sortingKey`. Common props: `as` (`'Draggable'` | `'DraggableRoot'`), `disabled`, `className`.
- **`SortableItemArea`**: Optional inner drag handle. Only meaningful when its parent `SortableItem` is `as='DraggableRoot'`. Style this as the visible drag affordance (e.g. a grip icon).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!