Column Sizing

Column Sizing

Column sizing gives every column a width, a minimum, and a maximum, and lets you override the width per column at runtime. The package computes numbers; you decide what they mean in your markup, which is almost always pixels. This page ports TanStack Table's Column Sizing (React) Guide.

If you want the user to drag a column edge to change a width, that is Column Resizing, which is a drag interaction built on top of the state described here.

State

Column sizing owns one state slice: the committed width of each column, keyed by column id.

-- in Table.State
columnSizing : Dict String Float

-- in Table.initialState
columnSizing = Dict.empty

A column with no entry falls back to its own size, and then to the table's defaultColumn. An entry is a raw number; the clamping between minSize and maxSize happens on read, in getColumnSize, so a committed width outside the bounds is stored as written and reported clamped.

Config options

Option Type Default Description
defaultColumn SizeDefaults { size = 150, minSize = 20, maxSize = 9007199254740991 } The sizing every column starts from.

SizeDefaults is a plain record:

type alias SizeDefaults =
    { size : Float
    , minSize : Float
    , maxSize : Float
    }

9007199254740991 is JavaScript's Number.MAX_SAFE_INTEGER, which is what TanStack uses for "no maximum".

Set it with withDefaultColumn, which is TanStack's tableOptions.defaultColumn:

config : Table.Config Person
config =
    Table.config
        [ Table.column "firstName" (.firstName >> Value.String)
            |> Table.withHeader "First name"
            |> Table.withSize 220
        , Table.column "lastName" (.lastName >> Value.String)
            |> Table.withHeader "Last name"
        , Table.column "age" (.age >> toFloat >> Value.Number)
            |> Table.withHeader "Age"
            |> Table.withMinSize 60
            |> Table.withMaxSize 120
        ]
        |> Table.withDefaultColumn { size = 180, minSize = 40, maxSize = 600 }

Column options

Builder Type Default Description
withSize Float -> Column row -> Column row defaultColumn.size This column's starting width.
withMinSize Float -> Column row -> Column row defaultColumn.minSize The narrowest this column reports.
withMaxSize Float -> Column row -> Column row defaultColumn.maxSize The widest this column reports.

Read them back, with the config defaults already applied, using columnSize, columnMinSize, and columnMaxSize. Those three are the column definition's own numbers and ignore State.columnSizing; getColumnSize is the state-aware one and is what you render with.

A column whose minSize is larger than its maxSize reports maxSize. That looks wrong and is deliberate: it is what TanStack's clamp does, and the ported test asserts it.

Transitions

Function Signature Description
setColumnSize String -> Float -> State -> State Commit one column's width.
setColumnSizing Dict String Float -> State -> State Replace the whole map.
resetColumnSize String -> State -> State Drop one column's committed width, leaving the others alone.
resetColumnSizing State -> State Drop every committed width.
update : Msg -> Model -> Model
update msg model =
    case msg of
        SetSize columnId width ->
            { model | state = Table.setColumnSize columnId width model.state }

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

        ResetAllSizes ->
            { model | state = Table.resetColumnSizing model.state }

To start from widths you have persisted, write the map once:

startingWidths : Table.State
startingWidths =
    Table.setColumnSizing
        (Dict.fromList [ ( "firstName", 260 ), ( "age", 80 ) ])
        Table.initialState

Queries

Function Signature Description
getColumnSize Config row -> State -> Column row -> Float The rendered width: the committed size when there is one, else the column's own size, else the default, clamped.
getColumnStart Config row -> State -> ColumnRegion -> Column row -> Float How far from the start of its region the column begins.
getColumnAfter Config row -> State -> ColumnRegion -> Column row -> Float How far from the end of its region the column ends.
getHeaderSize Config row -> State -> Header row -> Float A header's width: its column's size for a leaf header, the sum of its sub-headers for a parent header.
getHeaderStart Config row -> State -> List (Header row) -> Header row -> Float How far from the start of its header row the header begins.
totalSize Config row -> State -> Float The width of the whole table: the sum of the top header row.
leftTotalSize Config row -> State -> Float The width of the left-pinned region.
centerTotalSize Config row -> State -> Float The width of the unpinned region.
rightTotalSize Config row -> State -> Float The width of the right-pinned region.

getColumnStart and getColumnAfter take a ColumnRegion, which replaces TanStack's optional position argument.

getHeaderStart takes the headers of the row the header belongs to, because a Header does not point back at its header group. Pass group.headers for the group you are rendering.

Applying the widths

The numbers become inline style "width" attributes. Nothing in the package writes CSS, so the unit is yours to choose:

px : Float -> String
px n =
    String.fromFloat n ++ "px"
viewHeaderCell : Table.State -> Table.Header Person -> Html Msg
viewHeaderCell state header =
    th
        [ style "width" (px (Table.getHeaderSize config state header)) ]
        [ text (Table.headerColumnId header) ]

Give the table itself the total, and table-layout: fixed so the browser honours the per-cell widths instead of computing its own:

viewTable : Table.State -> List (Html Msg) -> Html Msg
viewTable state children =
    table
        [ style "width" (px (Table.totalSize config state))
        , style "table-layout" "fixed"
        ]
        children

The offsets are what a pinned column needs for its sticky position. A left-pinned column sits at getColumnStart from the left edge of the left region, and a right-pinned column at getColumnAfter from the right edge of the right region:

stickyLeft : Table.State -> Table.Column Person -> List (Html.Attribute Msg)
stickyLeft state column =
    [ style "position" "sticky"
    , style "left" (px (Table.getColumnStart config state Table.leftColumnsRegion column))
    , style "width" (px (Table.getColumnSize config state column))
    ]
stickyRight : Table.State -> Table.Column Person -> List (Html.Attribute Msg)
stickyRight state column =
    [ style "position" "sticky"
    , style "right" (px (Table.getColumnAfter config state Table.rightColumnsRegion column))
    , style "width" (px (Table.getColumnSize config state column))
    ]

Not covered

TanStack's page also describes owning the columnSizing state through the atoms option or state.columnSizing plus onColumnSizingChange. There is nothing to port: State.columnSizing is always yours, held in your own model.

resetColumnSizing here is TanStack's resetColumnSizing(true): it empties the map. There is no table.initialState to restore, so to go back to widths you started with, call setColumnSizing with the map you remembered.

Example

Column Sizing, ported from TanStack's Column Sizing example.