Tables
A table is the right element when your data has rows and columns: prices, timetables, scores. This lesson shows how to build one that screen readers can follow, how to join cells, how to style whole columns, and how to keep a wide table usable on a small screen.
When to use a table
Use a table for data that you would also put in a spreadsheet: every cell means something because of its row and its column. A list of prices, a school timetable and a table of results are all tables. A page that has a menu on the left and text on the right is not: that is a job for layout with CSS, which the layout lesson teaches. Screen readers announce a table as a table and read each cell together with its headings, so a table used for layout makes the page harder to understand for the people who depend on them.
The parts of a table
A table holds rows, and a row holds cells. A cell is written column in Salam. The name can mislead you: column is one cell (HTML td), and the rows are what make up the columns. Rows can go straight into the table, but real tables are split into three sections: header for the headings, main for the data and footer for totals. Inside a table these three words mean the HTML thead, tbody and tfoot. Outside a table the same words are the page header, the main part and the page footer, and the compiler picks the right one from where you wrote it.
The caption is the title of the table. It must be the first thing inside the table: the compiler stops with an error if it is not. A table can have only one header and one footer, but as many main sections as you need to group rows.
page:
title = "Price list"
global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" end
global: selector = "th, td" padding = "8px 14px" border bottom = "1px solid #cbd5e1" text align = left end
global: selector = "table" border collapse = collapse end
table:
caption: content = "Fruit prices this week" end
header:
row:
column: content = "Fruit" end
column: content = "Kilos" end
column: content = "Price" end
end
end
main:
row:
column: content = "Apples" end
column: content = "3" end
column: content = "4.50" end
end
row:
column: content = "Pears" end
column: content = "2" end
column: content = "3.20" end
end
end
footer:
row:
column: content = "Total" end
column: content = "5" end
column: content = "7.70" end
end
end
end
endTwo global rules do the styling here. th, td selects every cell, and the rule gives them padding and a line under them. table with border collapse = collapse joins the lines of neighbouring cells into one. Without CSS a table has no borders at all, so every table you want to look like a table needs some.
The cells of the header row became th without you asking. That is the context rule: a column inside header is a heading cell. Browsers draw heading cells in bold and centred, and, more important, they tell assistive technology that these cells are headings. The footer is different. It only holds data cells unless you say otherwise, so the word Total in the last row is plain here. The next section fixes that.
Heading cells and scope
Headings are not only in the top row. When the first cell of every row names that row, it is a heading too. Write heading = true on a column and it becomes a th. The word heading here is an attribute of the cell, not the heading element of the text lesson.
Then tell the browser which way the heading points with scope: col for a heading that describes the cells below it and row for one that describes the cells to its right. There are two more values for headings that cover a whole group of rows or columns: rowgroup and colgroup. For a simple table browsers guess the scope correctly, but writing it costs nothing and removes the guess.
If a heading is long, give it a short form in abbr. A screen reader may read the short form when it repeats the heading for every cell, which saves the listener time.
page:
title = "Row and column headers"
global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" end
global: selector = "th, td" padding = "8px 16px" border = "1px solid #cbd5e1" end
global: selector = "table" border collapse = collapse end
table:
caption: content = "Quiz scores" end
header:
row:
column: content = "Student" scope = col end
column: content = "Quiz 1" scope = col abbr = "Q1" end
column: content = "Quiz 2" scope = col abbr = "Q2" end
end
end
main:
row:
column: heading = true scope = row content = "Sara" end
column: content = "17" end
column: content = "19" end
end
row:
column: heading = true scope = row content = "Omid" end
column: content = "14" end
column: content = "18" end
end
end
end
endheader column and heading column are the two names the reference below gives to the heading cell: the first is a cell that stands in a header row, the second a cell that you marked with heading = true. Both are written column in your code and both produce th.
Cells that span
A cell can cover more than one place. colspan stretches it over several columns and rowspan over several rows. When you stretch a cell, the cells it covers must not be written: a row with a colspan = 2 cell has one cell less, and the row below a rowspan = 2 cell has one cell less on that side. If you write too many, the table looks broken and no error appears, so count the cells of every row as you write it.
page:
title = "Timetable"
global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" end
global: selector = "th, td" padding = "10px 18px" border = "1px solid #94a3b8" text align = center end
global: selector = "table" border collapse = collapse end
table:
caption: content = "Tuesday" end
header:
row:
column: content = "Time" end
column: content = "Room A" end
column: content = "Room B" end
end
end
main:
row:
column: heading = true content = "09:00" end
column: content = "Maths" rowspan = 2 background = "#dbeafe" end
column: content = "Art" end
end
row:
column: heading = true content = "10:00" end
column: content = "Music" end
end
row:
column: heading = true content = "12:00" end
column: content = "Lunch for everyone" colspan = 2 background = "#fef3c7" end
end
end
end
endIn the timetable the Maths cell has rowspan = 2, so the second row lists only Music, which fills Room B. The lunch row has colspan = 2 and fills both rooms.
Complex tables and headers
Sometimes one heading is not enough to explain a cell. In a table with two levels of headings, or with spanning cells, a data cell may belong to several headings. Give each heading cell an id and list those ids in the headers attribute of the data cell, separated by spaces. Salam checks that every id exists. The example below gives every data cell both of its headings, the row heading and the column heading, so you can see the shape. Do not write headers on a simple table: scope is enough there and is much less work.
page:
title = "Opening hours"
global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" end
global: selector = "th, td" padding = "8px 16px" border = "1px solid #cbd5e1" end
global: selector = "table" border collapse = collapse end
table:
caption: content = "Opening hours of two shops" end
row:
column: heading = true id = "shop" content = "Shop" end
column: heading = true id = "wk" content = "Weekdays" end
column: heading = true id = "we" content = "Weekend" end
end
row:
column: heading = true id = "bk" content = "Books" end
column: content = "9-18" headers = "bk wk" end
column: content = "10-14" headers = "bk we" end
end
row:
column: heading = true id = "cf" content = "Coffee" end
column: content = "7-20" headers = "cf wk" end
column: content = "8-22" headers = "cf we" end
end
end
endStyling whole columns
You often want a colour or a width for a whole column. Writing it on every cell is long and easy to get wrong. A column group before the sections of the table lets you describe the columns once. Each column definition inside stands for one column from the left (from the right in a Persian page). Give it span to cover several columns with one line. Only a few properties work on these elements: background, width, visibility and, when borders are collapsed, border. They have no text and no children.
page:
title = "Styling columns"
global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" end
global: selector = "th, td" padding = "8px 16px" end
global: selector = "table" border collapse = collapse end
table:
column group:
column definition: background = "#e2e8f0" end
column definition: span = 2 background = "#ecfccb" end
column definition: background = "#fee2e2" end
end
header:
row:
column: content = "City" end
column: content = "Mon" end
column: content = "Tue" end
column: content = "Wed" end
end
end
main:
row:
column: heading = true content = "Oslo" end
column: content = "4" end
column: content = "6" end
column: content = "3" end
end
row:
column: heading = true content = "Rome" end
column: content = "18" end
column: content = "21" end
column: content = "19" end
end
end
end
endThe first definition colours the first column grey, the second one has span = 2 and colours the next two green, and the last one colours the final column red.
Making a table look good
Four properties do most of the work. border collapse = collapse merges the borders of neighbouring cells into one line, where the default separate draws a double line. border spacing sets the gap between cells when they are separate. padding on the cells makes the text breathe, and text align lines the numbers up, usually to the right. vertical align chooses where a short cell's text sits in a tall row.
Stripes make long rows easier to follow. You can colour every other row yourself, but it is simpler to write one CSS rule. The page-wide style element takes raw CSS, and tbody tr:nth-child(even) selects every second row of the body. The hover colour uses the hover prefix in front of a style property, written on the row; the CSS lessons cover such states in detail. The table itself gets width = "100%" so that it fills the space, and max width stops it from growing past 28rem. color = white on the heading cells keeps the text readable on the dark green. The caption sits above the table by default, and caption side = bottom moves it under.
page:
title = "A styled table"
global: selector = "body" font family = "system-ui, sans-serif" margin = "1.5rem" end
global: selector = "th, td" padding = 10 text align = left end
global: selector = "th" background = "#0f766e" color = white end
style: content = "tbody tr:nth-child(even) { background: #f1f5f9 }" end
table:
border collapse = collapse
width = "100%"
max width = "28rem"
caption: content = "Team members" caption side = bottom color = "#64748b" padding = 8 end
header:
row:
column: content = "Name" end
column: content = "Role" end
column: content = "Age" end
end
end
main:
row: hover background = "#ccfbf1"
column: content = "Sara" end
column: content = "Designer" end
column: content = "31" end
end
row: hover background = "#ccfbf1"
column: content = "Omid" end
column: content = "Engineer" end
column: content = "28" end
end
row: hover background = "#ccfbf1"
column: content = "Lina" end
column: content = "Writer" end
column: content = "35" end
end
end
end
endDo not make a table narrower with width on every cell. Put width or max width on the table, and use table layout = fixed if you want the columns to take the widths of the first row, which also makes large tables draw faster.
Wide tables on small screens
A table is as wide as its content needs, and a phone is narrow. If the table cannot shrink, it pushes the whole page sideways. The fix is a box around the table with overflow = auto: the page keeps its width and the table scrolls inside the box. Give the box a max height as well and it scrolls up and down too, and then a heading row with position = sticky and top = 0 stays visible while the data moves under it. Put the sticky rule on the th cells, not on the row: that is the form that works in every browser.
page:
title = "Scrolling table"
global: selector = "body" font family = "system-ui, sans-serif" margin = "1rem" end
global: selector = "th, td" padding = 8 text align = left end
global: selector = "th" position = sticky top = 0 background = "#1e293b" color = white end
box:
max width = "26rem"
max height = "11rem"
overflow = auto
border = "1px solid #cbd5e1"
table:
border collapse = collapse
width = "40rem"
header:
row:
column: content = "Day" end
column: content = "Steps" end
column: content = "Distance" end
column: content = "Sleep" end
end
end
main:
row: column: content = "Monday" end column: content = "8200" end column: content = "6.1 km" end column: content = "7 h" end end
row: column: content = "Tuesday" end column: content = "10400" end column: content = "7.8 km" end column: content = "6 h" end end
row: column: content = "Wednesday" end column: content = "5900" end column: content = "4.4 km" end column: content = "8 h" end end
row: column: content = "Thursday" end column: content = "9100" end column: content = "6.8 km" end column: content = "7 h" end end
row: column: content = "Friday" end column: content = "12000" end column: content = "9.0 km" end column: content = "6 h" end end
end
end
end
endScroll the preview above to see the heading stay in place. The box has a max width and a max height that are smaller than the table, overflow = auto adds the scroll bars only when they are needed, and the table is given a fixed width of 40rem so that it is wider than the box. Put a background on sticky cells, or the text that scrolls under them shows through.
Accessible tables, in short
- Give every table a
caption. It says what the table is before anyone reads a cell. - Make every heading a heading cell, in a
headerrow or withheading = true, and writescope. - Do not leave a cell empty to make space: use padding for space. A cell that really has no data can say "none" or "n/a", so that a screen reader has something to read.
- Keep the table simple. Two tables are better than one with spans in every row.
Reference: every table element
Each element is shown with an example, and the attributes that belong only to that element are listed under it. The attributes that every element has are in the lesson on the head and global attributes. The examples are checked by the compiler, so you can paste any of them into a page: block. Persian names work in any file, and the Persian lessons use them.
table (Persian: جدول; HTML: <table>)
A table of data in rows and columns. Use it for information that has a natural grid, such as prices, schedules or results, and never to lay out a page. Browsers draw no borders by default, so style it with CSS. Its children are, in this order: an optional caption, optional column groups, the sections header, main and footer (or plain rows), and cells go inside rows.
table:
caption: content = "Pets" end
row:
column: content = "Cat" end
column: content = "4 legs" end
end
end
caption (Persian: عنوان جدول; HTML: <caption>)
The title of the table. It must be the first child of the table, and the compiler reports an error when it is not. Screen readers announce it before the cells, so it is better than a heading placed above the table. caption side moves it below.
table:
caption: content = "Pets" end
row: column: content = "Cat" end end
end
column group (Persian: گروه ستون; HTML: <colgroup>)
A group of column definitions that describe whole columns, so that you can style a column without touching every cell. It goes before the sections of the table. Without children it covers as many columns as its span says.
table:
column group:
column definition: background = "#fee2e2" end
column definition: end
end
row:
column: content = "Red column" end
column: content = "Plain column" end
end
end
| Attribute | What it does | Example |
|---|---|---|
spanپوشش | A whole number, the count of columns this group covers when it has no column definition children. Without it the group covers one column. [uint] | table:
column group: span = 2 background = "#e0f2fe" end
row:
column: content = "A" end
column: content = "B" end
end
end |
column definition (Persian: تعریف ستون; HTML: <col>)
Describes one column, or several with span, inside a column group. It has no content and no children. Only a few properties work on it: background, border, width and visibility. Text properties on it do nothing.
table:
column group:
column definition: width = 160 background = "#fef9c3" end
end
row: column: content = "Wide yellow cell" end end
end
| Attribute | What it does | Example |
|---|---|---|
spanپوشش | A whole number, how many neighbouring columns this one definition covers. Use it to style three columns the same way with one line. [uint] | table:
column group:
column definition: span = 3 background = "#dcfce7" end
end
row:
column: content = "A" end
column: content = "B" end
column: content = "C" end
end
end |
table header (Persian: سرصفحه; HTML: <thead>)
Written as header inside a table. It becomes the thead section, which holds the heading rows and repeats on every page when the table is printed. A table has at most one. The same word outside a table is the page header element, the compiler decides by where it stands. Cells in its rows turn into heading cells.
table:
header:
row:
column: content = "Name" end
column: content = "Age" end
end
end
main:
row:
column: content = "Sara" end
column: content = "31" end
end
end
end
table body (Persian: اصلی; HTML: <tbody>)
Written as main inside a table. It becomes tbody, the section with the data rows. You may have several of them to group rows. Rows written directly inside the table, with no section, work too, and then the browser adds an implicit body.
table:
main:
row:
column: content = "Sara" end
column: content = "31" end
end
end
end
table footer (Persian: پاورقی; HTML: <tfoot>)
Written as footer inside a table. It becomes tfoot, the place for a totals row. A table has at most one. Browsers draw it at the bottom even though it can be written before the body.
table:
main:
row:
column: content = "Tea" end
column: content = "3" end
end
end
footer:
row:
column: heading = true content = "Total" end
column: content = "3" end
end
end
end
row (Persian: ردیف; HTML: <tr>)
One row of cells. It goes inside the table or inside header, main or footer, and its children are cells.
table:
row:
column: content = "Left" end
column: content = "Right" end
end
end
column (Persian: ستون; HTML: <td>)
A data cell, the td element. Despite its name it is one cell, not a whole column. Inside a header row it becomes a heading cell automatically, and anywhere else you ask for a heading cell with heading = true.
table:
row:
column: content = "Plain data" end
column: content = "More data" background = "#f1f5f9" end
end
end
| Attribute | What it does | Example |
|---|---|---|
colspanپوشش ستون | A whole number, how many columns this cell stretches over to the right (or to the left in a right to left page). The next cells of the row are pushed along, so write fewer of them. [uint] | table:
row:
column: content = "Wide cell" colspan = 2 background = "#fef3c7" end
end
row:
column: content = "One" end
column: content = "Two" end
end
end |
rowspanپوشش ردیف | A whole number, how many rows this cell stretches down. The rows below must leave out the cells that it covers. [uint] | table:
row:
column: content = "Tall" rowspan = 2 background = "#dbeafe" end
column: content = "Top" end
end
row:
column: content = "Bottom" end
end
end |
headersسرستونها | A list of ids, separated by spaces, of the heading cells that describe this data cell. It is for tables so complex that scope is not enough. Every id must exist in the same page, and the compiler checks that. [idref (list)] | table:
row:
column: heading = true id = "h-day" content = "Day" end
column: heading = true id = "h-sales" content = "Sales" end
end
row:
column: content = "Monday" headers = "h-day" end
column: content = "120" headers = "h-sales h-day" end
end
end |
header column (Persian: ستون; HTML: <th>)
The name of a column when it stands in a header row: the compiler writes th for it, with no extra work from you. It takes scope (col or row, colgroup or rowgroup), abbr (a short form that screen readers read instead of a long heading), and also colspan, rowspan and headers.
table:
header:
row:
column: content = "Temperature" scope = col abbr = "Temp" end
column: content = "Rain" scope = col end
end
end
end
heading column (Persian: ستون; HTML: <th>)
A heading cell outside the header, made by writing heading = true on a column. Use it for the first cell of every row (a row heading) with scope = row, or in a footer for the word Total. It takes the same attributes as the header cell, scope and abbr among them.
table:
row:
column: heading = true scope = row content = "Monday" end
column: content = "Sunny" end
end
end
Try it yourself
- Make a table of your week with the days as columns and morning, noon and evening as rows. Use
heading = truewithscope = rowfor the times. - Add a footer to the price list above with a total row, and style the total in bold with a line above it.
- Take the scrolling table and widen it to 60rem. What changes on a narrow window? Then remove the
overflowline and look at the page.