Column Resizing

Column Resizing

Column resizing is dragging a column edge to a new width. The package holds the transient state of the drag, does the arithmetic, and writes the widths it computes into State.columnSizing; the handle, the pointer events, and the subscription are yours, because nothing here touches the DOM. This page ports TanStack Table's Column Resizing (React) Guide.

Resizing builds on Column Sizing. If you only need starting, minimum and maximum widths, that page is enough on its own.

Enable column resizing

Every column can be resized by default. Turn it off for the whole table with Config.enableColumnResizing, or per column with withEnableResizing:

config : Table.Config Person
config =
    let
        base : Table.Config Person
        base =
            Table.config
                [ Table.column "firstName" (.firstName >> Value.String)
                    |> Table.withHeader "First name"
                , Table.column "lastName" (.lastName >> Value.String)
                    |> Table.withHeader "Last name"
                , Table.column "age" (.age >> toFloat >> Value.Number)
                    |> Table.withHeader "Age"
                    |> Table.withEnableResizing False
                ]
                |> Table.withDefaultColumn { size = 150, minSize = 60, maxSize = 800 }
    in
    { base
        | enableColumnResizing = True
        , columnResizeMode = Table.resizeOnChange
        , columnResizeDirection = Table.resizeLtr
    }

Ask columnCanResize or, on a header, headerCanResize before rendering a handle. Both are True only when the column and the table allow it.

Column resize mode

Config.columnResizeMode decides when a drag writes to State.columnSizing.

Mode What happens
resizeOnChange Every pointer move commits the new widths, so the table follows the pointer. The default here.
resizeOnEnd Moves only track the drag in State.columnResizing; the widths are committed when the drag ends. Draw an indicator from deltaOffset while it runs.
onEndConfig : Table.Config Person -> Table.Config Person
onEndConfig cfg =
    { cfg | columnResizeMode = Table.resizeOnEnd }

The default is resizeOnEnd, as in TanStack, where an "onChange" drag re-renders a whole React table on every frame. Elm's virtual DOM diff makes that much cheaper, so resizeOnChange is a reasonable choice for most tables; keep resizeOnEnd for tables with expensive cells.

Column resize direction

Config.columnResizeDirection is the sign of the drag. resizeLtr, the default, grows a column when the pointer moves right; resizeRtl grows it when the pointer moves left, which is what a right-to-left layout needs:

rtlConfig : Table.Config Person -> Table.Config Person
rtlConfig cfg =
    { cfg | columnResizeDirection = Table.resizeRtl }

State

Resizing owns one state slice, and it is transient: it is empty between drags.

-- in Table.State
columnResizing : ColumnResizingState

-- in Table.initialState
columnResizing =
    { columnSizingStart = []
    , deltaOffset = Nothing
    , deltaPercentage = Nothing
    , isResizingColumn = Nothing
    , startOffset = Nothing
    , startSize = Nothing
    }
Field What it holds
columnSizingStart The width every leaf header under the dragged header had when the drag began, as ( column id, width ).
deltaOffset How far the pointer has moved from startOffset, with the direction's sign.
deltaPercentage That distance as a fraction of startSize, floored at -0.999999.
isResizingColumn The column being dragged. TanStack's false is Nothing here.
startOffset The pointer position the drag started at.
startSize The width of the dragged header when the drag started.

The committed widths live in State.columnSizing, not here. See Column Sizing.

Transitions

TanStack does the whole drag inside the closure header.getResizeHandler() returns, which installs its own document listeners. That closure is three state transitions here, and you decide which events call them.

Function Signature Call it on
startColumnResize Config row -> State -> Header row -> Float -> State mousedown and touchstart, with the event's clientX.
updateColumnResize Config row -> Float -> State -> State Every mousemove and touchmove, with the event's clientX.
endColumnResize Config row -> State -> State mouseup, touchend, and touchcancel.

startColumnResize records startOffset, startSize, the leaf widths, and the column id, and does nothing at all for a column that cannot be resized. updateColumnResize writes deltaOffset and deltaPercentage, and in resizeOnChange mode the new widths as well. endColumnResize commits the widths the last move computed and then clears the slice, so pass the ending position through updateColumnResize first when the event carries one, as a mouseup does. A touchend carries no position, so calling endColumnResize alone commits the last position a move reported.

Two more transitions cover the rest of TanStack's table API: setColumnResizing replaces the slice, and resetColumnResizing empties it, which is table.resetHeaderSizeInfo(true).

update : Msg -> Model -> Model
update msg model =
    case msg of
        ResizeStarted header x ->
            { model | state = Table.startColumnResize config model.state header x }

        ResizeMoved x ->
            { model | state = Table.updateColumnResize config x model.state }

        ResizeEnded x ->
            { model
                | state =
                    model.state
                        |> Table.updateColumnResize config x
                        |> Table.endColumnResize config
            }

        ResizeReleased ->
            { model | state = Table.endColumnResize config model.state }

        SizeReset columnId ->
            { model | state = Table.resetColumnSize columnId model.state }

Connecting the transitions to your UI

Column size APIs

The widths a drag produces are read back with the column sizing queries, which is where the numbers for your markup come from:

Table.getHeaderSize config state header
Table.getColumnSize config state column

The resize handle

The handle is a small absolutely positioned element on the trailing edge of the header cell. mousedown and touchstart both start the drag; the touch decoder reads touches[0].clientX and rounds it, the way TanStack does:

resizeHandle : Table.State -> Table.Header Person -> Html Msg
resizeHandle state header =
    if not (Table.headerCanResize config header) then
        text ""

    else
        div
            [ class "resizer"
            , classList [ ( "is-resizing", Table.headerIsResizing state header ) ]
            , Html.Events.onDoubleClick (SizeReset (Table.headerColumnId header))
            , Html.Events.on "mousedown" (Decode.map (ResizeStarted header) clientX)
            , Html.Events.on "touchstart" (Decode.map (ResizeStarted header) touchClientX)
            , Html.Events.preventDefaultOn "touchmove"
                (Decode.map (\x -> ( ResizeMoved x, True )) touchClientX)
            , Html.Events.on "touchend" (Decode.succeed ResizeReleased)
            , Html.Events.on "touchcancel" (Decode.succeed ResizeReleased)
            ]
            []
clientX : Decode.Decoder Float
clientX =
    Decode.field "clientX" Decode.float
touchClientX : Decode.Decoder Float
touchClientX =
    Decode.at [ "touches", "0", "clientX" ] Decode.float
        |> Decode.map (round >> toFloat)

A double click on the handle is the usual way to give a column its default width back, which is resetColumnSize.

The header cell itself takes its width from getHeaderSize:

viewHeaderCell : Table.State -> Table.Header Person -> Html Msg
viewHeaderCell state header =
    th
        [ style "width" (String.fromFloat (Table.getHeaderSize config state header) ++ "px")
        , style "position" "relative"
        ]
        [ text (Maybe.withDefault "" (headerText header))
        , resizeHandle state header
        ]

Following the pointer

A mouse leaves the five-pixel handle on the first move, so the moves have to be listened for on the document. Browser.Events does that, and the subscription only exists while isResizingColumn is set:

subscriptions : Model -> Sub Msg
subscriptions model =
    case model.state.columnResizing.isResizingColumn of
        Nothing ->
            Sub.none

        Just _ ->
            Sub.batch
                [ Browser.Events.onMouseMove (Decode.map ResizeMoved clientX)
                , Browser.Events.onMouseUp (Decode.map ResizeEnded clientX)
                ]

Touch events need no subscription: a touch gesture keeps firing at the element its touchstart hit, so touchmove, touchend and touchcancel stay on the handle, as they are in the snippet above.

A resize indicator

In resizeOnEnd mode the widths do not move until the drag ends, so the feedback during the drag is an indicator offset by deltaOffset:

resizeIndicator : Table.State -> Table.Header Person -> Html msg
resizeIndicator state header =
    let
        offset : Float
        offset =
            if Table.headerIsResizing state header then
                Maybe.withDefault 0 state.columnResizing.deltaOffset

            else
                0
    in
    div
        [ class "resize-indicator"
        , style "transform" ("translateX(" ++ String.fromFloat offset ++ "px)")
        ]
        []

headerIsResizing and columnIsResizing answer "is this the column being dragged?", which is also what a highlighted handle keys off.

Resizing performance

TanStack's advanced section is about keeping a drag off React's render path. The same shape works here, and buys more than it costs on a large table:

  1. Write the widths as CSS custom properties once. Put --header-<id>-size and --col-<id>-size on the table element, and give every cell width: calc(var(--col-firstName-size) * 1px). Only one attribute on one element changes per drag frame. Html.Attributes.style sets a style property, which a custom property is not, so write the whole attribute with Html.Attributes.attribute:

    
    

sizeVariables : Table.State -> String sizeVariables state = let forHeader : Table.Header Person -> List String forHeader header = [ "--header-" ++ Table.headerId header ++ "-size:" ++ px (Table.getHeaderSize config state header) , "--col-" ++ Table.headerColumnId header ++ "-size:" ++ px (columnWidth state (Table.headerColumnId header)) ] in String.join ";" (("width:" ++ px (Table.totalSize config state)) :: List.concatMap forHeader (Table.flatHeaders config state) )


2.  **Keep the body out of the drag.** Once the cells read their widths from
    the variables, the body markup no longer mentions the state, so
    `Html.Lazy` skips rebuilding it entirely while a column is dragged:

    ```elm
viewTable : Model -> Html Msg
viewTable model =
    table [ attribute "style" (sizeVariables model.state) ]
        [ Html.thead []
            (List.map
                (\group -> Html.tr [] (List.map (viewHeaderCell model.state) group.headers))
                (Table.headerGroups config model.state)
            )
        , Html.Lazy.lazy viewBody model.data
        ]

Elm does not have React's problem in the first place: an unchanged subtree diffs to no DOM patches, so a resizeOnChange drag on an ordinary table is already smooth without any of this.

Not covered

  • header.getResizeHandler() itself, and everything it installs: the document listeners, their removal, the passive-listener check, the multi-touch guard, and the requestAnimationFrame coalescing of pointer moves. Those are the event plumbing above, and Elm's subscriptions already deliver at most one message per frame's worth of pointer moves.
  • Owning columnResizing through the atoms option or through state.columnResizing plus onColumnResizingChange. The whole State is already yours, held in your own model.
  • table.resetHeaderSizeInfo() with no argument, which restores table.initialState. There is no table instance to hold an initial state; keep the slice you want to go back to and pass it to setColumnResizing.

Example

Column Resizing, with both modes and both directions, and Performant Column Resizing, which renders the body from the CSS variables above.