Column Pinning

Column Pinning

Pinning holds a column against the left or right edge of the table while the rest scroll. The state is a pair of column id lists, and the package hands you the three column slices, the three header slices, and the three cell slices that a pinned layout needs. This page ports TanStack Table's Column Pinning (React) Guide.

There are two ways to render it, and the package supports both. Keep every column in one table and use position: sticky CSS on the pinned cells, or render the left, center, and right regions as three separate tables side by side. The split layout is what the region functions below exist for.

How pinning affects column order

Column order is decided in one place, and the pin split is its last step:

allColumns -> State.columnOrder -> Config.groupedColumnMode -> visibility -> pin split

The order inside the left and right regions comes from State.columnPinning itself, in the order those lists hold. State.columnOrder therefore only decides the order of the unpinned ("center") columns. See Column Ordering.

State

Column pinning owns one state slice: the ids pinned to each edge.

-- in Table.State
columnPinning : ColumnPinning

-- the type
type alias ColumnPinning =
    { left : List String
    , right : List String
    }

-- in Table.initialState
columnPinning = { left = [], right = [] }

TanStack calls these fields start and end, which are logical directions: in a left-to-right layout start is the left edge, and in a right-to-left layout it is the right edge. This port names them left and right, because that is what a caller writing CSS is thinking about. The start / end wording survives in one place only: the header ids of the per-region header groups, which read start_1_identity_firstName, because the ported test asserts those exact strings.

Ids in left and right are leaf column ids. pinColumn removes a column from both lists before adding it to one, so it never lands in both. If you write the record yourself and put an id in both, the left list wins.

Config options

Option Type Default Description
enableColumnPinning Bool True Turns pinning off for the whole table. With it False, columnCanPin is False for every column.

Config is a plain record, so you set it with a record update: { config | enableColumnPinning = False }.

Column options

Builder Type Default Description
withEnablePinning Bool -> Column row -> Column row True Allow or forbid pinning this column.

A group column can be pinned when at least one leaf below it allows it, and pinning a group column pins every leaf below it.

config : Table.Config Person
config =
    Table.config
        [ Table.column "firstName" (.firstName >> Value.String)
            |> Table.withHeader "First name"
        , Table.column "lastName" (.lastName >> Value.String)
            |> Table.withHeader "Last name"
        , Table.column "department" (.department >> Value.String)
            |> Table.withHeader "Department"
        , Table.column "actions" (\_ -> Value.Null)
            |> Table.withHeader "Actions"
            |> Table.withEnablePinning False
        ]

Transitions

Function Signature Description
pinColumn ColumnPinPosition -> Column row -> State -> State Pin one column to an edge, or unpin it.
setColumnPinning ColumnPinning -> State -> State Replace both lists at once.
resetColumnPinning State -> State Unpin every column.

ColumnPinPosition is an abstract type with three values, standing in for TanStack's 'start' | 'end' | false:

Value Meaning
pinnedLeft Pinned to the left edge. TanStack's 'start'.
pinnedRight Pinned to the right edge. TanStack's 'end'.
columnUnpinned Not pinned. TanStack's false.

Wire pinColumn to a message that carries the position and the column:

update : Msg -> Model -> Model
update msg model =
    case msg of
        Pin position column ->
            { model | state = Table.pinColumn position column model.state }

        ClearPinning ->
            { model | state = Table.resetColumnPinning model.state }

To start with columns already pinned, write the state once:

pinnedByDefault : Table.State
pinnedByDefault =
    Table.setColumnPinning
        { left = [ "firstName" ], right = [ "actions" ] }
        Table.initialState

Queries

Regions

Most read functions come in four shapes: one per region, plus a general one taking a ColumnRegion. ColumnRegion replaces TanStack's optional position argument, which cannot be optional in Elm.

Value Meaning
allColumnsRegion Every column. What TanStack means by leaving position out.
leftColumnsRegion The left-pinned columns.
centerColumnsRegion The unpinned columns.
rightColumnsRegion The right-pinned columns.

Per-column

Function Signature Description
columnCanPin Config row -> Column row -> Bool Can this column be pinned?
columnIsPinned State -> Column row -> ColumnPinPosition Where is it pinned? A group column reports its first pinned leaf, left before right.
columnPinnedIndex State -> Column row -> Int Its position inside its pinned region. An unpinned column gives 0, matching TanStack.

A pin control renders from those two:

pinControls : Table.State -> Table.Column Person -> Html Msg
pinControls state column =
    if not (Table.columnCanPin config column) then
        text ""

    else if Table.columnIsPinned state column == Table.columnUnpinned then
        span []
            [ button [ onClick (Pin Table.pinnedLeft column) ] [ text "Pin left" ]
            , button [ onClick (Pin Table.pinnedRight column) ] [ text "Pin right" ]
            ]

    else
        button [ onClick (Pin Table.columnUnpinned column) ] [ text "Unpin" ]

Per-table

Function Signature Description
isSomeColumnsPinned State -> Bool Is anything pinned to either edge?
isSomeColumnsPinnedLeft State -> Bool Anything pinned left?
isSomeColumnsPinnedRight State -> Bool Anything pinned right?

Column lists

Function Signature Description
leftLeafColumns Config row -> State -> List (Column row) Left-pinned leaf columns, hidden ones included.
centerLeafColumns Config row -> State -> List (Column row) Unpinned leaf columns, in table order.
rightLeafColumns Config row -> State -> List (Column row) Right-pinned leaf columns.
pinnedLeafColumns Config row -> State -> ColumnRegion -> List (Column row) The same three by region.
leftVisibleLeafColumns Config row -> State -> List (Column row) Left-pinned leaf columns that are visible.
centerVisibleLeafColumns Config row -> State -> List (Column row) Visible unpinned leaf columns.
rightVisibleLeafColumns Config row -> State -> List (Column row) Right-pinned leaf columns that are visible.
pinnedVisibleLeafColumns Config row -> State -> ColumnRegion -> List (Column row) The same three by region. allColumnsRegion gives visibleLeafColumns unchanged.
pinnedColumns Config row -> State -> PinnedColumns row All three visible slices at once, as { left, center, right }.

PinnedColumns row is a plain record:

type alias PinnedColumns row =
    { left : List (Column row)
    , center : List (Column row)
    , right : List (Column row)
    }

visibleLeafColumns is not pin-ordered. It keeps table order and ignores pinning entirely, exactly as TanStack's getVisibleLeafColumns does. The pin partition lives in two other places: the header seam, which is what the per-region header group functions below build on, and visibleCells, which returns the left cells, then the center cells, then the right cells. So a one-table sticky layout that renders headers with the header groups and cells with visibleCells is already in pin order without asking for it.

Function Signature
leftHeaderGroups / centerHeaderGroups / rightHeaderGroups Config row -> State -> List (HeaderGroup row)
leftFooterGroups / centerFooterGroups / rightFooterGroups Config row -> State -> List (HeaderGroup row)
leftFlatHeaders / centerFlatHeaders / rightFlatHeaders Config row -> State -> List (Header row)
leftLeafHeaders / centerLeafHeaders / rightLeafHeaders Config row -> State -> List (Header row)

A flat header list is every header of every row of that region; a leaf header list is only the headers with no sub-headers. See Header Groups and Headers.

Cell lists

Function Signature Description
leftVisibleCells Config row -> State -> Row row -> List Cell The row's visible cells whose column is pinned left.
centerVisibleCells Config row -> State -> Row row -> List Cell Its visible unpinned cells.
rightVisibleCells Config row -> State -> Row row -> List Cell Its visible cells pinned right.

Sticky layout

One table, one row, three groups of cells, each with its own class:

viewRow : Table.State -> Table.Row Person -> Html Msg
viewRow state row =
    tr []
        (List.map (viewCell "pinned-left") (Table.leftVisibleCells config state row)
            ++ List.map (viewCell "unpinned") (Table.centerVisibleCells config state row)
            ++ List.map (viewCell "pinned-right") (Table.rightVisibleCells config state row)
        )

The left and right CSS offsets a sticky column needs are widths, so they come from Column Sizing: getColumnStart with leftColumnsRegion, and getColumnAfter with rightColumnsRegion.

Split layout

Three tables, each built from one region's headers and one region's cells:

viewLeftTable : Table.State -> Table.RowModel Person -> Html Msg
viewLeftTable state model =
    table []
        [ thead []
            (List.map viewHeaderRow (Table.leftHeaderGroups config state))
        , tbody []
            (List.map
                (\row -> tr [] (List.map (viewCell "pinned-left") (Table.leftVisibleCells config state row)))
                (Table.rowsInDisplayOrder config state model)
            )
        ]
viewHeaderRow : Table.HeaderGroup Person -> Html Msg
viewHeaderRow group =
    tr [] (List.map (\header -> th [] [ text (Table.headerColumnId header) ]) group.headers)

Not covered

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

resetColumnPinning here is TanStack's resetColumnPinning(true): it clears both lists. There is no table.initialState to restore, so to go back to a pinning you started with, call setColumnPinning with the record you remembered.

TanStack marks each returned cell with a position field. This port does not: the lists carry the same cells unchanged, and you know the region from the function you called.

Example

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

Two more layouts of the same feature: Column Pinning (Split) renders the three regions as three separate tables, and Sticky Column Pinning keeps them in one table with position: sticky.