{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "column-resizer",
  "title": "Column Resizer",
  "description": "Drag the line between two column headings to set that column's width, in a <table> you already have — with the widths kept where you put them, a keyboard path through the same control, and double-click to fit a column to its contents. Reach for it wherever a table has more columns than the default widths can serve: an admin data grid, a logs or audit-trail view, an orders, invoices or billing-history list, a transactions or ledger table, an inventory or stock grid, a spreadsheet-like editor, a CRM or support-ticket queue, search results with a long title column, an analytics or reporting breakdown, a file and document browser, and any table where one column holds a URL, a path, an error message or an email and every other column is three characters wide. Common asks it answers: \"resizable table columns react\", \"drag to resize table column react\", \"react resizable column width\", \"shadcn resizable table columns\", \"shadcn table column resize\", \"shadcn data table column resizing\", \"resizable columns without tanstack table\", \"tanstack table column resizing alternative\", \"react-table column resize alternative\", \"ag-grid just for column widths\", \"column width not applying\", \"width on th ignored\", \"style width on table cell does nothing\", \"table ignores column width\", \"table-layout fixed not working\", \"table-layout fixed does nothing\", \"colgroup width not working\", \"col element width ignored\", \"resizing one column changes all the others\", \"all columns resize together\", \"column width snaps back after drag\", \"column collapses to zero width\", \"cannot grab column resize handle\", \"resize handle too thin to click\", \"drag resize stops after one pixel\", \"column resize handle loses the pointer\", \"resize handle also sorts the column\", \"column resize triggers sort\", \"slow table while dragging column width\", \"column resize laggy large table\", \"accessible column resize\", \"keyboard resizable table column\", \"resize column with arrow keys\", \"aria-valuenow separator resize\", \"role separator splitter react\", \"column resize rtl\", \"persist column widths\", \"save column widths to localstorage\", \"restore column widths on reload\", \"double click to autofit column width\", \"excel autofit column react\", \"touch drag resizes scrolls the page instead\". Not official shadcn/ui's resizable, which is a thin wrapper over react-resizable-panels: that one splits a layout into panels with percentage sizes and a flex container, and cannot be used across a table's <th> elements at all, because a row's children have to be cells. This is the table case, with no dependency added. The reason a hand-rolled one does not hold its widths is measurable, and it is not the thing everyone reaches for. In a 600px scroll container, asking for three 120px columns: table-layout: fixed with width: auto gives [140, 243, 120] — the automatic algorithm's own answer, because a fixed layout needs a definite width to be in effect at all, so the property can be set and be doing nothing (width: max-content is not definite either, and behaves the same). With width: 100% it gives [200, 200, 200] — the space the columns did not ask for is shared out between them, so dragging one column moves every column and the one you dragged does not land where you let it go, which is exactly where shadcn/ui's own <Table> puts you because it renders w-full. Only with width: 360px, the sum, do you get [120, 120, 120]. So the table's width is not a style anyone picks; it is the sum of the columns, recomputed on every drag, which is what this hands you. Adding min-width: 100% to make a narrow table fill its container puts it straight back to [199.66, 199.66, 199.69]; and a <col> left unsized among sized ones is not given its content's width but whatever is left over, which is nothing, so it measures 0 and the column disappears — which is why every column is given a width and not just the ones somebody dragged. The handle is a role=\"separator\" with aria-valuenow, aria-valuemin, aria-valuemax and arrow keys, plus Page Up/Down and Home/End, so a column can be resized without a pointer — the version without that is a feature a keyboard user cannot see is missing, and an absent aria-valuemax is 100 per ARIA, so a handle reporting 240 announces a nonsense percentage. It is nine pixels of transparent strip around a one-pixel line, because the line is what you aim at and a one-pixel target is not one; it captures the pointer, or widening the column moves the handle out from under the finger and the drag ends on its first move; it calls preventDefault and stopPropagation so a resize does not also sort the column underneath; a cancelled pointer reverts instead of committing a width nobody chose, and so does Escape; and the gesture is measured from its origin rather than accumulated per move, so a drag pushed past the minimum widens again the moment it comes back instead of owing the distance back. Right-to-left tables mirror both the drag and the arrow keys, read from the element at the time of the gesture. onWidthsChange fires once per gesture with the whole map, not per pointer move, because a column width is a stored preference and not something to write to localStorage sixty times a second — during the drag only a guide line moves, and nothing re-renders. Pass preview=\"live\" to resize the column itself as you drag, which is still written straight to the DOM rather than through state. Composes with the other two pieces that go in a <th> and leave your markup alone: sort-header for the heading and sticky-table-header for pinning the header row and the leading columns. Widths are a plain Record<string, number> keyed by column id, so they survive hiding and reordering columns and can be stored and restored as they are. useColumnResizer, resolveColumnWidths, tableWidthFor, columnWidthFromDrag, keyboardColumnWidth and autoFitColumnWidth are exported for a header laid out as divs. One file, no dependencies, and your <table> stays yours.",
  "files": [
    {
      "path": "registry/ui/column-resizer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The narrowest a column may be dragged. 48px holds a sort arrow and two characters.\n *\n * A minimum is not a nicety. Dragged to zero a column stops rendering its own edge, which is the\n * thing you grab to get it back — so the column is gone and there is no gesture that returns it.\n */\nexport const DEFAULT_MIN_COLUMN_WIDTH = 48\n\n/**\n * The widest a column may be dragged, unless a wider one is asked for.\n *\n * This exists for `aria-valuemax` rather than for taste. A `separator` acting as a splitter is a\n * value widget, and ARIA says an absent `aria-valuemax` is 100 — so a handle reporting\n * `aria-valuenow=\"240\"` with no maximum is telling a screen reader the column is at 240 on a scale\n * that ends at 100, and what gets announced is a nonsense percentage. Any honest number is better\n * than none; this one is \"wider than any screen a table is read on\".\n */\nexport const DEFAULT_MAX_COLUMN_WIDTH = 960\n\n/** Width change per arrow key press. */\nexport const DEFAULT_KEY_STEP = 16\n\n/** Width change per Page Up / Page Down. */\nexport const DEFAULT_KEY_STEP_LARGE = 64\n\n/**\n * The grab area's width in pixels, centred on the column's edge.\n *\n * A 1px border is the thing you are aiming at and roughly nobody can hit it; the pointer target has\n * to be several pixels wide while still *looking* like the border, which is why the handle is a\n * transparent strip with a thin visible line inside it rather than a styled border.\n */\nexport const DEFAULT_HANDLE_WIDTH = 9\n\n/** Column widths in pixels, keyed by column id. A column missing from it has not been resized. */\nexport type ColumnWidthMap = Readonly<Record<string, number>>\n\nexport interface ColumnWidthBounds {\n  min?: number\n  max?: number\n}\n\n/**\n * `width` clamped into `[min, max]`, with anything unusable replaced by `min`.\n *\n * The `NaN` arm is the one that matters. A width is arithmetic on a pointer coordinate, and one\n * `undefined` anywhere in that sum produces `NaN`, which compares false against both bounds and so\n * survives every `if (w < min)` guard written the obvious way. It then reaches the DOM as\n * `width: NaNpx`, which is not a length, so the declaration is dropped — and a `<col>` with no width\n * under a fixed layout is the one case that collapses the column to nothing (see\n * {@link resolveColumnWidths}). The failure is a column that vanishes, a long way from the\n * arithmetic that caused it.\n */\nexport function clampColumnWidth(\n  width: number,\n  { min = DEFAULT_MIN_COLUMN_WIDTH, max = DEFAULT_MAX_COLUMN_WIDTH }: ColumnWidthBounds = {}\n): number {\n  const low = Number.isFinite(min) ? min : DEFAULT_MIN_COLUMN_WIDTH\n  const high = Number.isFinite(max) ? Math.max(low, max) : Number.POSITIVE_INFINITY\n  // Only a value that is not a number, or is NaN, is unusable. The infinities are not: they have an\n  // order, so they clamp to the bound they are heading for, and sending +Infinity to the *minimum*\n  // — which `!Number.isFinite` does — is the one wrong answer available.\n  if (typeof width !== \"number\" || Number.isNaN(width)) return low\n  return Math.min(high, Math.max(low, width))\n}\n\nexport interface ResolveColumnWidthsOptions extends ColumnWidthBounds {\n  /** Width for a column with no entry in the map. Defaults to 160. */\n  defaultWidth?: number\n}\n\n/**\n * Every column's width, in `columns` order, with no gaps.\n *\n * **Every column has to be given a width, not just the resized ones.** Under `table-layout: fixed`\n * on a table with a definite width, a `<col>` carrying no width of its own is handed whatever is\n * left over after the sized ones — and because this component sets the table's width to exactly the\n * sum of the sized columns, what is left over is zero. Measured: three columns, two at 120px and\n * one left unsized, produce used widths of `[120, 120, 0]`. The unsized column does not fall back\n * to its content or to some share of the table; it disappears, and it is a column nobody ever\n * touched, so the drag that triggered it looks unrelated.\n *\n * That is also why this returns a dense array rather than the sparse map it is given: the map is\n * the *user's* choices and is meant to be sparse, and the gap between the two is exactly where that\n * bug lives.\n */\nexport function resolveColumnWidths(\n  columns: readonly string[],\n  widths: ColumnWidthMap | undefined,\n  { defaultWidth = 160, min, max }: ResolveColumnWidthsOptions = {}\n): number[] {\n  const fallback = clampColumnWidth(defaultWidth, { min, max })\n  return columns.map((column) => {\n    const stored = widths?.[column]\n    return stored === undefined ? fallback : clampColumnWidth(stored, { min, max })\n  })\n}\n\n/**\n * The width the `<table>` itself must carry: the sum of its columns.\n *\n * **This is the whole component, and it is not what \"use `table-layout: fixed` and a `<colgroup>`\"\n * suggests.** Both of those are necessary and neither is sufficient. Measured, in a 600px scroll\n * container, asking for three 120px columns:\n *\n * - `table-layout: fixed` with `width: auto` — used widths `[140, 243, 120]`, identical to what the\n *   automatic algorithm produces. A fixed layout needs a *definite* width to be a layout at all;\n *   without one the property is inert and the content sizes the columns, so the first thing anyone\n *   checks (\"is `table-layout: fixed` set?\") can be yes while nothing about it is in effect.\n *   `width: max-content` behaves the same way — it is not definite either.\n * - `table-layout: fixed` with `width: 100%` — used widths `[200, 200, 200]`. Now the property is\n *   live, and the 240px the three columns did not ask for is shared out between them. Drag one\n *   column and every column moves; the one you dragged does not land where you left it. This is the\n *   arrangement shadcn/ui's own `<Table>` puts you in, because it renders `w-full`.\n * - `table-layout: fixed` with `width: 360px` — used widths `[120, 120, 120]`. Exactly as asked.\n *\n * So the column widths are the used widths only when their sum *is* the table's width, which means\n * the table's width is not a style someone picks; it is a value this component computes and keeps\n * in step with every drag. Hand it out through {@link UseColumnResizerResult.tableProps} and do not\n * leave `w-full` on the element.\n */\nexport function tableWidthFor(widths: readonly number[]): number {\n  let total = 0\n  for (const width of widths) total += Number.isFinite(width) ? width : 0\n  return total\n}\n\n/**\n * Where each column starts, measured from the table's left edge.\n *\n * Used to place the drag guide, which has to stand on a column boundary rather than under the\n * pointer: the pointer is wherever the hand is, and a line that follows it exactly says the column\n * edge is somewhere it is not once the width has been clamped at either end.\n */\nexport function columnOffsets(widths: readonly number[]): number[] {\n  const out: number[] = []\n  let sum = 0\n  for (const width of widths) {\n    out.push(sum)\n    sum += Number.isFinite(width) ? width : 0\n  }\n  return out\n}\n\n/** `widths` with one column set to `width`, clamped. Never mutates. */\nexport function resizeColumnWidth(\n  widths: ColumnWidthMap | undefined,\n  column: string,\n  width: number,\n  bounds: ColumnWidthBounds = {}\n): ColumnWidthMap {\n  return { ...(widths ?? {}), [column]: clampColumnWidth(width, bounds) }\n}\n\nexport interface DragWidthOptions extends ColumnWidthBounds {\n  /** Whether the table reads right-to-left, which mirrors the gesture. */\n  rtl?: boolean\n}\n\n/**\n * The width a drag has reached: the width it started at, plus how far the pointer has travelled.\n *\n * Computed from the *start* of the gesture rather than from the previous move, because the two are\n * not the same once the width is clamped. Accumulating per-move deltas means a drag that pushes 200\n * pixels past the minimum has to be dragged 200 pixels back before the column widens again — the\n * hand moves and nothing happens, which reads as the handle having been dropped. Re-deriving from\n * the origin makes the clamp a floor the column rests on instead of a debt it has to repay.\n *\n * **In a right-to-left table the gesture is mirrored.** The handle sits on the column's left edge\n * there, so dragging left makes the column wider. Getting this wrong does not look like a direction\n * bug; it looks like the handle resizing the neighbouring column.\n */\nexport function columnWidthFromDrag(\n  startWidth: number,\n  startX: number,\n  x: number,\n  { rtl = false, min, max }: DragWidthOptions = {}\n): number {\n  const travelled = x - startX\n  return clampColumnWidth(startWidth + (rtl ? -travelled : travelled), { min, max })\n}\n\nexport interface KeyboardWidthOptions extends DragWidthOptions {\n  step?: number\n  largeStep?: number\n}\n\n/**\n * The width a key press asks for, or `null` for a key this handle does not claim.\n *\n * **Without this the feature is mouse-only, and nothing on screen says so.** The handle is visible,\n * it has a focus ring, a screen reader announces a separator with a value — and then no key changes\n * it. Returning `null` rather than the current width is what lets the caller leave the event alone:\n * a handle that swallows every key takes Tab with it and traps focus between the columns.\n *\n * `Home` and `End` go to the bounds rather than scrolling the table, which is what those keys mean\n * on a value widget.\n */\nexport function keyboardColumnWidth(\n  key: string,\n  current: number,\n  {\n    step = DEFAULT_KEY_STEP,\n    largeStep = DEFAULT_KEY_STEP_LARGE,\n    rtl = false,\n    min = DEFAULT_MIN_COLUMN_WIDTH,\n    max = DEFAULT_MAX_COLUMN_WIDTH,\n  }: KeyboardWidthOptions = {}\n): number | null {\n  const bounds = { min, max }\n  const grow = rtl ? \"ArrowLeft\" : \"ArrowRight\"\n  const shrink = rtl ? \"ArrowRight\" : \"ArrowLeft\"\n  switch (key) {\n    case grow:\n      return clampColumnWidth(current + step, bounds)\n    case shrink:\n      return clampColumnWidth(current - step, bounds)\n    case \"PageUp\":\n      return clampColumnWidth(current + largeStep, bounds)\n    case \"PageDown\":\n      return clampColumnWidth(current - largeStep, bounds)\n    case \"Home\":\n      return clampColumnWidth(min, bounds)\n    case \"End\":\n      return clampColumnWidth(max, bounds)\n    default:\n      return null\n  }\n}\n\nexport interface AutoFitOptions extends ColumnWidthBounds {\n  /** Added to the widest cell so the text is not flush against the column edge. Defaults to 24. */\n  padding?: number\n}\n\n/**\n * The width that would show every cell in a column in full, or `null` if nothing could be measured.\n *\n * `scrollWidth` is the right ruler and the only one available: under a fixed layout the cell is\n * already as narrow as the column, so its *own* box says nothing about its content, while\n * `scrollWidth` is the content's width whether or not it is being clipped. Rounded up, because a\n * fraction down is a clipped last glyph.\n *\n * `null` for an empty column rather than a number, so the caller can leave the width alone instead\n * of snapping it to the minimum — a column of empty cells is almost always a column still loading.\n */\nexport function autoFitColumnWidth(\n  cells: readonly { scrollWidth?: number }[],\n  { padding = 24, min, max }: AutoFitOptions = {}\n): number | null {\n  let widest = 0\n  for (const cell of cells) {\n    const width = cell?.scrollWidth\n    if (typeof width === \"number\" && Number.isFinite(width)) widest = Math.max(widest, width)\n  }\n  if (widest <= 0) return null\n  return clampColumnWidth(Math.ceil(widest + padding), { min, max })\n}\n\n/**\n * Whether `node` sits in a right-to-left table.\n *\n * Read from the element at the moment of the gesture rather than kept in state, because direction is\n * inherited and can change under the component — an `<html dir>` toggle, a single column of Arabic\n * in an otherwise English page — and a cached answer silently resizes the wrong way afterwards.\n * Asked of the node itself, not of `document`, for the same reason: `dir` is per-subtree.\n */\nexport function isRtlElement(node: Element | null | undefined): boolean {\n  if (!node || typeof getComputedStyle !== \"function\") return false\n  return getComputedStyle(node).direction === \"rtl\"\n}\n\n/** A drag in progress. */\nexport interface ColumnResizeSession {\n  column: string\n  /** Pointer x at the moment of the press. */\n  startX: number\n  /** The column's width at the moment of the press — the origin every move is measured from. */\n  startWidth: number\n  /** The width the drag has reached. */\n  width: number\n  pointerId: number\n}\n\nexport function beginColumnResize(init: {\n  column: string\n  startX: number\n  startWidth: number\n  pointerId?: number\n}): ColumnResizeSession {\n  return {\n    column: init.column,\n    startX: init.startX,\n    startWidth: init.startWidth,\n    width: init.startWidth,\n    pointerId: init.pointerId ?? -1,\n  }\n}\n\n/** `session` advanced to pointer x. Never mutates. */\nexport function advanceColumnResize(\n  session: ColumnResizeSession,\n  x: number,\n  options: DragWidthOptions = {}\n): ColumnResizeSession {\n  return {\n    ...session,\n    width: columnWidthFromDrag(session.startWidth, session.startX, x, options),\n  }\n}\n\n/** How the table behaves while the pointer is down. */\nexport type ColumnResizePreview = \"guide\" | \"live\"\n\nexport interface ColumnResizerLabels {\n  /** The handle's accessible name. */\n  handle: (column: string) => string\n  /** Spoken value. Pixels, said as a word — \"240\" alone is read as a quantity of nothing. */\n  value: (width: number) => string\n}\n\nexport const defaultColumnResizerLabels: ColumnResizerLabels = {\n  handle: (column) => `Resize ${column} column`,\n  value: (width) => `${Math.round(width)} pixels`,\n}\n\nexport interface UseColumnResizerOptions extends ColumnWidthBounds {\n  /** Column ids, in the order the table renders them. */\n  columns: readonly string[]\n  /** Controlled widths. Pair with `onWidthsChange`. */\n  widths?: ColumnWidthMap\n  /** Starting widths when uncontrolled. */\n  defaultWidths?: ColumnWidthMap\n  /**\n   * Called once per gesture, with the whole map.\n   *\n   * Once, not per pointer move: a column width is a preference worth storing, and a listener that\n   * writes to `localStorage` or to a server is not something to run sixty times a second. During\n   * the drag the preview is applied to the DOM directly and no state changes at all — see\n   * {@link ColumnResizePreview}.\n   */\n  onWidthsChange?: (widths: ColumnWidthMap) => void\n  /** Width for a column with no stored entry. Defaults to 160. */\n  defaultWidth?: number\n  /** What moves while the pointer is down. Defaults to `\"guide\"`. */\n  preview?: ColumnResizePreview\n  step?: number\n  largeStep?: number\n  /** Double-click a handle to fit the column to its contents. Defaults to `true`. */\n  autoFit?: boolean\n  labels?: Partial<ColumnResizerLabels>\n}\n\nexport interface ColumnResizeHandleAttributes {\n  role: \"separator\"\n  tabIndex: 0\n  \"aria-orientation\": \"vertical\"\n  \"aria-label\": string\n  \"aria-valuenow\": number\n  \"aria-valuemin\": number\n  \"aria-valuemax\": number\n  \"aria-valuetext\": string\n  \"data-resizing\"?: \"true\"\n  onPointerDown: (event: React.PointerEvent<HTMLElement>) => void\n  onPointerMove: (event: React.PointerEvent<HTMLElement>) => void\n  onPointerUp: (event: React.PointerEvent<HTMLElement>) => void\n  onPointerCancel: (event: React.PointerEvent<HTMLElement>) => void\n  onLostPointerCapture: (event: React.PointerEvent<HTMLElement>) => void\n  onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void\n  onDoubleClick: (event: React.MouseEvent<HTMLElement>) => void\n}\n\nexport interface UseColumnResizerResult {\n  columns: readonly string[]\n  /** Every column's width, in order — dense, clamped, never `NaN`. */\n  widths: number[]\n  /** The width the `<table>` must carry. See {@link tableWidthFor}. */\n  totalWidth: number\n  /** The column being dragged, or `null`. */\n  resizing: string | null\n  /** Spread on the scroll wrapper. Makes it the guide's containing block and the scrollport. */\n  wrapperProps: {\n    ref: React.RefObject<HTMLDivElement | null>\n    \"data-resizing\"?: \"true\"\n  }\n  /**\n   * Spread on the `<table>`. Carries `table-layout: fixed` and the computed width.\n   *\n   * Deliberately not applied from the wrapper with a descendant rule, the way a wrapper component\n   * would normally style the table it is given. The width here is the single value the whole\n   * component rests on, it has to beat whatever `w-full` is already on the element, and a\n   * class-based override that a consumer's Tailwind build never generates fails by leaving the\n   * table at `width: auto` — where `table-layout: fixed` goes quiet and the columns silently size\n   * themselves to their content again. An inline style cannot not be generated.\n   */\n  tableProps: {\n    ref: React.RefObject<HTMLTableElement | null>\n    style: React.CSSProperties\n  }\n  /** Spread on the `<col>` for `column`. */\n  colProps: (column: string) => { ref: (node: HTMLTableColElement | null) => void; style: React.CSSProperties }\n  /** Spread on the handle inside `column`'s `<th>`. */\n  handleProps: (column: string) => ColumnResizeHandleAttributes\n  /** Spread on the guide line. Rendered inside the wrapper, not inside a cell. */\n  guideProps: { ref: React.RefObject<HTMLDivElement | null>; hidden: boolean; style: React.CSSProperties }\n  /** Set a column's width directly — a \"reset widths\" button, or a width restored from storage. */\n  setColumnWidth: (column: string, width: number) => void\n}\n\n/**\n * The behaviour of a resizable column set, without any of its markup.\n *\n * Holds the widths, the drag, the keyboard and the measurements; renders nothing. {@link ColumnResizer},\n * {@link ColumnWidths} and {@link ColumnResizeHandle} are thin wrappers over what this returns, and\n * exist so the common case is not twenty lines of prop spreading.\n */\nexport function useColumnResizer({\n  columns,\n  widths: controlledWidths,\n  defaultWidths,\n  onWidthsChange,\n  defaultWidth = 160,\n  min = DEFAULT_MIN_COLUMN_WIDTH,\n  max = DEFAULT_MAX_COLUMN_WIDTH,\n  preview = \"guide\",\n  step = DEFAULT_KEY_STEP,\n  largeStep = DEFAULT_KEY_STEP_LARGE,\n  autoFit = true,\n  labels: labelOverrides,\n}: UseColumnResizerOptions): UseColumnResizerResult {\n  const isControlled = controlledWidths !== undefined\n  const [ownWidths, setOwnWidths] = React.useState<ColumnWidthMap>(defaultWidths ?? {})\n  const widthMap = isControlled ? controlledWidths : ownWidths\n\n  const bounds = React.useMemo(() => ({ min, max }), [min, max])\n  const resolved = React.useMemo(\n    () => resolveColumnWidths(columns, widthMap, { defaultWidth, min, max }),\n    [columns, widthMap, defaultWidth, min, max]\n  )\n  const totalWidth = tableWidthFor(resolved)\n\n  const labels = React.useMemo(\n    () => ({ ...defaultColumnResizerLabels, ...labelOverrides }),\n    [labelOverrides]\n  )\n\n  const wrapperRef = React.useRef<HTMLDivElement | null>(null)\n  const tableRef = React.useRef<HTMLTableElement | null>(null)\n  const guideRef = React.useRef<HTMLDivElement | null>(null)\n  const colRefs = React.useRef(new Map<string, HTMLTableColElement | null>())\n\n  // One render when the drag starts and one when it ends. Not the width: see `session` below.\n  const [resizing, setResizing] = React.useState<string | null>(null)\n  // The live width lives in a ref, which is the point. A table is the one component where the\n  // obvious implementation — width in state, re-render per pointer move — is visibly too slow: each\n  // move re-renders every row and then makes the browser lay the whole table out again, and at\n  // twenty columns the line stops keeping up with the hand. Refs let the drag write one style\n  // property on one element per move and leave React out of it until the pointer is released.\n  const session = React.useRef<ColumnResizeSession | null>(null)\n  // Where the table's left edge sits inside the wrapper's scrollable content, read once per gesture.\n  // The guide is positioned against the wrapper, so anything between the two — padding, a border, a\n  // toolbar — is an offset the boundary would otherwise be drawn without.\n  const originRef = React.useRef(0)\n  const rtlRef = React.useRef(false)\n\n  const commit = React.useCallback(\n    (column: string, width: number) => {\n      const next = resizeColumnWidth(widthMap, column, width, bounds)\n      if (!isControlled) setOwnWidths(next)\n      onWidthsChange?.(next)\n    },\n    [widthMap, bounds, isControlled, onWidthsChange]\n  )\n\n  const setColumnWidth = React.useCallback(\n    (column: string, width: number) => commit(column, width),\n    [commit]\n  )\n\n  /** Moves the guide, or the column itself, without telling React. */\n  const paint = React.useCallback(\n    (current: ColumnResizeSession) => {\n      const index = columns.indexOf(current.column)\n      if (index < 0) return\n      if (preview === \"live\") {\n        const col = colRefs.current.get(current.column)\n        if (col?.style) col.style.width = `${current.width}px`\n        // The table's width has to move with it, or the sum stops matching and the browser goes back\n        // to sharing the difference out across every column — the drag would pull its neighbours\n        // along with it. See tableWidthFor.\n        const table = tableRef.current\n        if (table?.style) {\n          const widthsNow = resolved.slice()\n          widthsNow[index] = current.width\n          table.style.width = `${tableWidthFor(widthsNow)}px`\n        }\n        return\n      }\n      const guide = guideRef.current\n      if (!guide?.style) return\n      const boundary = columnOffsets(resolved)[index] + current.width\n      guide.style.left = `${originRef.current + boundary}px`\n    },\n    [columns, preview, resolved]\n  )\n\n  const finish = React.useCallback(\n    (cancelled: boolean) => {\n      const current = session.current\n      session.current = null\n      setResizing(null)\n      if (!current) return\n      if (preview === \"live\") {\n        // Hand the element back to React. Left as it is, the inline width written during the drag\n        // outranks the one the next render puts there, so a parent that clamps or rejects the new\n        // width would be overruled by the leftovers of the gesture that proposed it.\n        const col = colRefs.current.get(current.column)\n        if (col?.style) col.style.removeProperty(\"width\")\n        const table = tableRef.current\n        if (table?.style) table.style.removeProperty(\"width\")\n      }\n      if (!cancelled && current.width !== current.startWidth) commit(current.column, current.width)\n    },\n    [commit, preview]\n  )\n\n  const handleProps = React.useCallback(\n    (column: string): ColumnResizeHandleAttributes => {\n      const index = columns.indexOf(column)\n      const width = index < 0 ? defaultWidth : resolved[index]\n      return {\n        role: \"separator\",\n        tabIndex: 0,\n        \"aria-orientation\": \"vertical\",\n        \"aria-label\": labels.handle(column),\n        \"aria-valuenow\": Math.round(width),\n        \"aria-valuemin\": Math.round(min),\n        \"aria-valuemax\": Math.round(max),\n        \"aria-valuetext\": labels.value(width),\n        ...(resizing === column ? { \"data-resizing\": \"true\" as const } : {}),\n        onPointerDown: (event) => {\n          // Secondary buttons open menus and must not start a gesture that only ends on pointerup.\n          if (event.button !== 0) return\n          // The press belongs to the handle, not to the header it sits in: the <th> underneath is a\n          // sort button in most tables, and without this a resize also sorts the column on release.\n          event.preventDefault()\n          event.stopPropagation()\n          const node = event.currentTarget\n          rtlRef.current = isRtlElement(node)\n          const wrapper = wrapperRef.current\n          const table = tableRef.current\n          originRef.current =\n            wrapper?.getBoundingClientRect && table?.getBoundingClientRect\n              ? table.getBoundingClientRect().left -\n                wrapper.getBoundingClientRect().left +\n                (wrapper.scrollLeft ?? 0)\n              : 0\n          const next = beginColumnResize({\n            column,\n            startX: event.clientX,\n            startWidth: width,\n            pointerId: event.pointerId,\n          })\n          session.current = next\n          // Capture, so the gesture survives the pointer leaving a 9px-wide strip — which it does\n          // immediately, because widening the column moves the handle out from under the finger.\n          // Without it the drag ends on the first move and the column jumps back.\n          node?.setPointerCapture?.(event.pointerId)\n          setResizing(column)\n          paint(next)\n        },\n        onPointerMove: (event) => {\n          const current = session.current\n          if (!current || current.pointerId !== event.pointerId) return\n          const next = advanceColumnResize(current, event.clientX, {\n            ...bounds,\n            rtl: rtlRef.current,\n          })\n          session.current = next\n          paint(next)\n        },\n        onPointerUp: (event) => {\n          const current = session.current\n          if (!current || current.pointerId !== event.pointerId) return\n          // Advanced to the release coordinate before finishing, rather than trusting the last move\n          // to have been the last word. Pointer moves are coalesced and the final one before a\n          // release is routinely dropped, so a drag that ends with a quick flick commits the width\n          // from some pixels back — a column that does not land where it was let go, every time the\n          // gesture is fast.\n          session.current = advanceColumnResize(current, event.clientX, {\n            ...bounds,\n            rtl: rtlRef.current,\n          })\n          finish(false)\n        },\n        // A cancelled pointer is not a release. The OS takes the pointer away for a phone call, an\n        // edge gesture, a palm on the trackpad — and at that moment the finger is wherever it had\n        // got to, so treating cancel as \"dropped here\" commits a width nobody chose.\n        onPointerCancel: () => finish(true),\n        onLostPointerCapture: () => {\n          if (session.current) finish(false)\n        },\n        onKeyDown: (event) => {\n          if (event.key === \"Escape\" && session.current) {\n            finish(true)\n            return\n          }\n          const next = keyboardColumnWidth(event.key, width, {\n            step,\n            largeStep,\n            // Read here, not from the ref the pointer path fills: that ref is only ever written by a\n            // drag, so on an RTL table every arrow key ran the wrong way until someone had dragged a\n            // column once — and then came right, which is worse than being consistently wrong.\n            rtl: isRtlElement(event.currentTarget),\n            min,\n            max,\n          })\n          // null means a key this handle does not claim — Tab above all, which has to keep working\n          // or focus is stuck on a column border.\n          if (next === null) return\n          event.preventDefault()\n          if (next !== width) commit(column, next)\n        },\n        onDoubleClick: () => {\n          if (!autoFit) return\n          const table = tableRef.current\n          if (!table?.querySelectorAll) return\n          const position = columns.indexOf(column) + 1\n          if (position <= 0) return\n          const cells = Array.from(table.querySelectorAll(`tr > *:nth-child(${position})`))\n          const fitted = autoFitColumnWidth(cells, { min, max })\n          if (fitted !== null) commit(column, fitted)\n        },\n      }\n    },\n    [\n      columns, resolved, defaultWidth, labels, min, max, resizing, bounds,\n      paint, finish, commit, step, largeStep, autoFit,\n    ]\n  )\n\n  const colProps = React.useCallback(\n    (column: string) => {\n      const index = columns.indexOf(column)\n      return {\n        ref: (node: HTMLTableColElement | null) => {\n          if (node) colRefs.current.set(column, node)\n          else colRefs.current.delete(column)\n        },\n        style: { width: `${index < 0 ? defaultWidth : resolved[index]}px` },\n      }\n    },\n    [columns, resolved, defaultWidth]\n  )\n\n  return {\n    columns,\n    widths: resolved,\n    totalWidth,\n    resizing,\n    wrapperProps: {\n      ref: wrapperRef,\n      ...(resizing ? { \"data-resizing\": \"true\" as const } : {}),\n    },\n    tableProps: {\n      ref: tableRef,\n      style: { tableLayout: \"fixed\", width: `${totalWidth}px` },\n    },\n    colProps,\n    handleProps,\n    guideProps: {\n      ref: guideRef,\n      hidden: resizing === null,\n      style: { left: 0 },\n    },\n    setColumnWidth,\n  }\n}\n\nconst ColumnResizerContext = React.createContext<UseColumnResizerResult | null>(null)\n\n/** The resizer from the nearest {@link ColumnResizer}, or `null` outside one. */\nexport function useColumnResizerContext(): UseColumnResizerResult | null {\n  return React.useContext(ColumnResizerContext)\n}\n\nfunction useResizer(explicit?: UseColumnResizerResult): UseColumnResizerResult {\n  const fromContext = useColumnResizerContext()\n  const resizer = explicit ?? fromContext\n  if (!resizer) {\n    throw new Error(\n      \"ColumnWidths and ColumnResizeHandle need a <ColumnResizer> above them, or a `resizer` prop.\"\n    )\n  }\n  return resizer\n}\n\nexport interface ColumnResizerProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  resizer: UseColumnResizerResult\n  /** Let the table scroll sideways when the columns outgrow the space. Defaults to `true`. */\n  scrollable?: boolean\n  /** Classes for the guide line. */\n  guideClassName?: string\n}\n\n/**\n * The scroll container around a resizable table, and the guide line that moves during a drag.\n *\n * ```tsx\n * const COLUMNS = [\"name\", \"status\", \"amount\"] as const\n * const [widths, setWidths] = React.useState({ name: 220, status: 140, amount: 120 })\n * const resizer = useColumnResizer({ columns: COLUMNS, widths, onWidthsChange: setWidths })\n *\n * <ColumnResizer resizer={resizer}>\n *   <table {...resizer.tableProps} className=\"text-sm\">\n *     <ColumnWidths />\n *     <thead>\n *       <tr className=\"border-b\">\n *         {COLUMNS.map((column) => (\n *           <th key={column} className=\"relative px-3 py-2 text-left font-medium\">\n *             <span className=\"block truncate capitalize\">{column}</span>\n *             <ColumnResizeHandle column={column} />\n *           </th>\n *         ))}\n *       </tr>\n *     </thead>\n *     <tbody>\n *       {rows.map((row) => (\n *         <tr key={row.id} className=\"border-b\">\n *           {COLUMNS.map((column) => (\n *             <td key={column} className=\"truncate px-3 py-2\">{row[column]}</td>\n *           ))}\n *         </tr>\n *       ))}\n *     </tbody>\n *   </table>\n * </ColumnResizer>\n * ```\n *\n * **Why the guide is here and not in the `<th>`.** It has to run the full height of the table, so it\n * is absolutely positioned against this wrapper. Put the same element inside the header cell and it\n * resolves against *that* instead the moment the cell is given `position: relative` — which the\n * handle needs — and the line becomes a 20-pixel tick in the header rather than a boundary through\n * the rows. Measured both ways: against the wrapper it spans the table and is clipped by the scroll\n * box; a `position: fixed` line escapes the scroll box altogether and paints down the page.\n *\n * **`truncate` on the cells is not decoration.** A fixed layout gives the column the width it was\n * told and lets the content overflow, so a long cell prints across its neighbour until something\n * clips it.\n *\n * Composes with the other two pieces that go in a `<th>`: `sort-header` for the heading itself and\n * `sticky-table-header` for pinning, each of which leaves the markup alone in the same way.\n */\nexport function ColumnResizer({\n  resizer,\n  scrollable = true,\n  className,\n  guideClassName,\n  children,\n  ...props\n}: ColumnResizerProps) {\n  const { wrapperProps, guideProps } = resizer\n  return (\n    <ColumnResizerContext.Provider value={resizer}>\n      <div\n        className={cn(\n          // `relative` is the guide's containing block, and `w-fit` keeps the wrapper from\n          // stretching past a table narrower than the page.\n          \"relative w-fit max-w-full\",\n          scrollable && \"overflow-x-auto\",\n          // While dragging, the cursor belongs to the whole surface rather than to the 9px strip the\n          // pointer has already left, and selecting text mid-drag turns the gesture into a highlight.\n          resizer.resizing && \"cursor-col-resize select-none\",\n          className\n        )}\n        {...wrapperProps}\n        {...props}\n      >\n        {children}\n        <div\n          aria-hidden=\"true\"\n          className={cn(\n            \"pointer-events-none absolute top-0 bottom-0 z-30 w-px bg-primary\",\n            guideClassName\n          )}\n          {...guideProps}\n        />\n      </div>\n    </ColumnResizerContext.Provider>\n  )\n}\n\nexport interface ColumnWidthsProps extends React.ComponentPropsWithoutRef<\"colgroup\"> {\n  resizer?: UseColumnResizerResult\n}\n\n/**\n * The `<colgroup>` that carries the widths. Place it as the first child of the `<table>`.\n *\n * The widths go on `<col>` elements rather than on the `<th>`s because one column is one `<col>` and\n * several cells: written on the header cell, the width is a claim about that cell which every other\n * cell in the column is free to contradict, and under the automatic layout they do.\n *\n * Inline styles, not classes — a width computed at runtime has no class to be compiled from, which\n * is the trap in building one as `` `w-[${width}px]` ``: Tailwind reads source files as text, finds\n * no such class, and emits nothing.\n */\nexport function ColumnWidths({ resizer: explicit, ...props }: ColumnWidthsProps) {\n  const resizer = useResizer(explicit)\n  return (\n    <colgroup {...props}>\n      {resizer.columns.map((column) => (\n        <col key={column} {...resizer.colProps(column)} />\n      ))}\n    </colgroup>\n  )\n}\n\nexport interface ColumnResizeHandleProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    // Every key the handle itself sets. Spreading the caller's props last would otherwise let one\n    // stray `onKeyDown` replace the arrow-key support with nothing, and the component would still\n    // look right — a separator with a value that no key moves.\n    | \"role\"\n    | \"children\"\n    | \"tabIndex\"\n    | \"aria-orientation\"\n    | \"aria-label\"\n    | \"aria-valuenow\"\n    | \"aria-valuemin\"\n    | \"aria-valuemax\"\n    | \"aria-valuetext\"\n    | \"onPointerDown\"\n    | \"onPointerMove\"\n    | \"onPointerUp\"\n    | \"onPointerCancel\"\n    | \"onLostPointerCapture\"\n    | \"onKeyDown\"\n    | \"onDoubleClick\"\n  > {\n  column: string\n  resizer?: UseColumnResizerResult\n  /** Hide the thin line until the handle is hovered or focused. Defaults to `false`. */\n  subtle?: boolean\n}\n\n/**\n * The grab strip on a column's trailing edge. Goes inside the `<th>`, which needs `relative`.\n *\n * Wider than it looks on purpose — {@link DEFAULT_HANDLE_WIDTH} of transparent strip around a 1px\n * line — because the line is what you aim at and a 1px target is not one. It is a\n * `role=\"separator\"` with a value and arrow keys rather than a `<div onMouseDown>`, so the column\n * can be resized without a pointer at all; the version without that is a feature a keyboard user\n * cannot see is missing.\n *\n * `touch-none` matters on a phone: the browser claims a horizontal drag for panning before any\n * pointer handler runs, so without it the gesture scrolls the table instead of resizing it.\n */\nexport function ColumnResizeHandle({\n  column,\n  resizer: explicit,\n  subtle = false,\n  className,\n  style,\n  ...props\n}: ColumnResizeHandleProps) {\n  const resizer = useResizer(explicit)\n  const handle = resizer.handleProps(column)\n  const active = resizer.resizing === column\n  // The strip straddles the boundary it represents — except on the last column, where that boundary\n  // is the table's own right edge. Half a handle hanging past it is half a handle of content, and\n  // the scroll container around the table duly offers four pixels of horizontal scroll on a table\n  // that fits perfectly: a scrollbar that appears for nothing, which reads as a layout bug.\n  // Measured at 4px of overflow before this, 0 after.\n  const last = resizer.columns[resizer.columns.length - 1] === column\n  return (\n    <div\n      className={cn(\n        \"group absolute top-0 right-0 bottom-0 z-10 flex cursor-col-resize touch-none items-stretch justify-center\",\n        \"focus-visible:outline-none\",\n        className\n      )}\n      style={{\n        width: DEFAULT_HANDLE_WIDTH,\n        ...(last ? null : { transform: \"translateX(50%)\" }),\n        ...style,\n      }}\n      {...props}\n      {...handle}\n    >\n      {/* The line, inset so it reads as the column's edge rather than as a bar of its own. The ring\n          is drawn on this child because the strip is transparent and a focus ring around nothing is\n          hard to find — and it has to be visible on focus, which is the only way a keyboard user\n          knows which of twenty borders the arrow keys are about to move. */}\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"my-1 w-px rounded-full bg-border transition-colors\",\n          \"group-hover:bg-primary\",\n          // Thicker, recoloured *and* ringed on focus. One of the three on its own is too quiet for\n          // a control whose whole body is transparent: the user has to be able to tell which of\n          // twenty identical column borders the arrow keys are now about to move.\n          \"group-focus-visible:w-0.5 group-focus-visible:bg-primary group-focus-visible:ring-1 group-focus-visible:ring-ring\",\n          subtle && \"bg-transparent group-hover:bg-primary\",\n          active && \"bg-primary w-0.5\"\n        )}\n      />\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
