Header Groups

A header group is one row of header cells. This page is the port of TanStack's Header Groups guide.

What are header groups?

Header groups are the <tr> elements of your <thead>. Most tables have one of them. You get more than one when your columns are nested, because each level of nesting needs its own header row.

Table.group is what nests columns. A group column has no accessor and no cells; it exists to put a header above the columns underneath it:

config : Table.Config Person
config =
    Table.config
        [ Table.column "id" (.id >> Value.String)
            |> Table.withHeader "#"
        , Table.group "name"
            [ Table.column "firstName" (.firstName >> Value.String)
                |> Table.withHeader "First"
            , Table.column "lastName" (.lastName >> Value.String)
                |> Table.withHeader "Last"
            ]
            |> Table.withHeader "Name"
        , Table.group "work"
            [ Table.column "department" (.department >> Value.String)
                |> Table.withHeader "Department"
            , Table.column "salary" (.salary >> Value.Number)
                |> Table.withHeader "Salary"
                |> Table.withFooter "Total"
            ]
            |> Table.withHeader "Work"
        ]

That config is three levels wide but two levels deep, so it produces two header rows: # / Name / Work on top, then First / Last / Department / Salary.

The id column is a leaf sitting at the top level, shallower than the deepest leaf. To keep the grid rectangular, the header builder puts a placeholder header above (or in the header rows alongside) each such column. Placeholders are covered in Headers; the short version is that you either render them as empty cells or merge them with rowSpan.

Where to get header groups from

TanStack elm-table
table.getHeaderGroups() headerGroups
table.getFooterGroups() footerGroups
table.getStartHeaderGroups() leftHeaderGroups
table.getCenterHeaderGroups() centerHeaderGroups
table.getEndHeaderGroups() rightHeaderGroups
table.getStartFooterGroups() leftFooterGroups
table.getCenterFooterGroups() centerFooterGroups
table.getEndFooterGroups() rightFooterGroups

Every one of them has the type Config row -> State -> List (HeaderGroup row). They need the State because column visibility, column order, and column pinning all change which columns end up in which header row.

headerGroups already handles pinning: when any column is pinned it builds the rows over the left-pinned columns, then the unpinned ones, then the right-pinned ones, in that order. Reach for the three regional functions only when you are laying the regions out as separate elements.

footerGroups is headerGroups reversed, bottom row first, matching TanStack.

Header group objects

HeaderGroup row is a plain record, not an opaque type:

type alias HeaderGroup row =
    { id : String
    , depth : Int
    , headers : List (Header row)
    }
  • id is built from the depth: "0", "1", and so on. The pinned variants prefix it with their region, keeping TanStack's start / center / end wording, so the left-pinned top row is "start_0" and the right-pinned one is "end_0".
  • depth is the row index among the header rows, zero for the top row.
  • headers are the header cells of that row, left to right.

Access header cells

Map over headerGroup.headers. This renders a <thead> with one <tr> per header group, merging the placeholder chains vertically with rowspan:

viewHead : Table.State -> Html msg
viewHead state =
    thead [] (List.map viewHeaderRow (Table.headerGroups config state))
viewHeaderRow : Table.HeaderGroup Person -> Html msg
viewHeaderRow headerGroup =
    tr [] (List.filterMap viewHeaderCell headerGroup.headers)
viewHeaderCell : Table.Header Person -> Maybe (Html msg)
viewHeaderCell header =
    if Table.headerRowSpan header == 0 then
        Nothing

    else
        Just
            (th
                [ colspan (Table.headerColSpan header)
                , rowspan (Table.headerRowSpan header)
                ]
                [ text (headerLabel header) ]
            )

A header with a rowSpan of 0 is covered by a header above it and is skipped, which is why viewHeaderRow uses List.filterMap. Everything else is drawn with both colspan and rowspan.

A header carries no column, only headerColumnId, so the label comes from a lookup:

headerLabel : Table.Header Person -> String
headerLabel header =
    let
        id : String
        id =
            Table.headerColumnId header
    in
    Table.findColumn config id
        |> Maybe.andThen Table.columnHeader
        |> Maybe.withDefault id

Footers

Footer rows come out bottom row first, which puts a spanning placeholder below the cells it would have to cover. The rowSpan trick above does not work there. In a <tfoot>, render placeholders as empty cells instead:

viewFoot : Table.State -> Html msg
viewFoot state =
    tfoot [] (List.map viewFooterRow (Table.footerGroups config state))
viewFooterRow : Table.HeaderGroup Person -> Html msg
viewFooterRow headerGroup =
    tr [] (List.map viewFooterCell headerGroup.headers)
viewFooterCell : Table.Header Person -> Html msg
viewFooterCell header =
    if Table.headerIsPlaceholder header then
        th [] []

    else
        th [ colspan (Table.headerColSpan header) ]
            [ text (footerLabel header) ]

The footer text comes from withFooter, read back with columnFooter:

footerLabel : Table.Header Person -> String
footerLabel header =
    Table.findColumn config (Table.headerColumnId header)
        |> Maybe.andThen Table.columnFooter
        |> Maybe.withDefault ""

Pinned regions

If you are drawing the three pinned regions separately, the regional functions return header rows of the same depth, so they zip together row by row:

viewPinnedHead : Table.State -> Html msg
viewPinnedHead state =
    let
        rowsOf : List (Table.HeaderGroup Person) -> List (List (Table.Header Person))
        rowsOf groups =
            List.map .headers groups
    in
    thead []
        (List.map3
            (\left center right -> tr [] (List.filterMap viewHeaderCell (left ++ center ++ right)))
            (rowsOf (Table.leftHeaderGroups config state))
            (rowsOf (Table.centerHeaderGroups config state))
            (rowsOf (Table.rightHeaderGroups config state))
        )

See Column Pinning for the state that drives this.

Nothing is memoized

Each call rebuilds the header rows from the Config and the State. Call headerGroups once in your view and pass the result down, rather than calling it inside a loop over rows.