`, ``, and ` `. It
+handles the visual layer, e.g. column widths, spacing, dividers, row, and sort affordances, while leaving the data-source,
+sort, and selection states to the consumer.
+
+## Anatomy
+
+The component is fully composable and consumers only need to include what they need. For example, if column headers are not
+required, then `` can be omitted.
+
+{`
+| Component | Renders | Responsibility |
+| --- | --- | --- |
+| \`\` | \`\` | The full width table, and tabular figures styling |
+| \`\` | \`\` | The column definitions, as the first child of \`\` |
+| \`\` | \` \` | Defining the column's sizing |
+| \`\` | \`\` and its single \`\` | The header row |
+| \`\` | \`\` | The column label and its optional sort control |
+| \`\` | \`\` | The data rows |
+| \`\` | \`\` | Row selection through \`aria-selected\` |
+| \`\` | \`\` | The data cells |
+`}
+
+## Basic usage
+
+Render ``s and `` within `` and `` to render data. If consistent column sizing is needed,
+define the columns in ``.
+
+
+
+## Props
+
+Every component forwards its native attributes, except for the deprecated [`border`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/table#border),
+[`width`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/col#width), and
+[`align`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/td#align) attributes on ``, ``, and
+``/``, respectively. All components accept `exceptionallySetClassName` instead of `className`.
+
+The three components below also include additional props:
+
+### ``
+
+
+
+### ``
+
+
+
+### ``
+
+
+
+## Row selection
+
+Setting `aria-selected` on a `` would mark it as selected.
+
+
+
+## With TanStack Table
+
+The `` components do not dictate what external model layer they are used with. TanStack Table,
+for example, would be a good option for driving the data, including
+[sorting](https://tanstack.com/table/latest/docs/framework/react/guide/sorting) and
+[pagination](https://tanstack.com/table/latest/docs/framework/react/guide/pagination).
+
+
diff --git a/src/table/table.module.css b/src/table/table.module.css
new file mode 100644
index 000000000..e5663d4f5
--- /dev/null
+++ b/src/table/table.module.css
@@ -0,0 +1,160 @@
+.table {
+ font-variant-numeric: tabular-nums;
+ border-collapse: collapse;
+}
+
+.headerRow {
+ height: 40px;
+ border-bottom: 1px solid var(--product-library-border-idle-tint);
+}
+
+.headerCell {
+ height: 40px;
+ padding: 0 var(--reactist-spacing-small);
+ vertical-align: middle;
+ user-select: none;
+}
+
+.headerCellSortable {
+ padding: 0;
+}
+
+.sortButton {
+ display: flex;
+ gap: var(--reactist-spacing-xsmall);
+ align-items: center;
+ width: 100%;
+ height: 40px;
+ padding: 0 var(--reactist-spacing-small);
+ border: 0;
+ color: inherit;
+ font: inherit;
+ text-align: inherit;
+ background: transparent;
+ cursor: pointer;
+}
+
+.headerLabel {
+ flex: 1 0 0;
+ min-width: 0;
+}
+
+.sortButton:hover {
+ background-color: var(--product-library-actionable-secondary-hover-fill);
+}
+
+.sortButton:focus-visible {
+ outline: 2px solid var(--product-library-display-primary-idle-tint);
+ outline-offset: -2px;
+}
+
+.sortButton:hover .sortIndicatorUnsorted,
+.sortButton:focus-visible .sortIndicatorUnsorted {
+ opacity: 1;
+}
+
+.row {
+ height: 48px;
+ background-color: var(--product-library-background-base-primary);
+ border-bottom: 1px solid var(--product-library-divider-primary);
+}
+
+.row[aria-selected] {
+ cursor: pointer;
+}
+
+.row[aria-selected]:hover {
+ background-color: var(--product-library-actionable-secondary-hover-fill);
+ border-bottom-color: var(--product-library-divider-tertiary);
+}
+
+.row[aria-selected='true'] {
+ background-color: var(--product-library-selectable-secondary-selected-fill);
+}
+
+.row[aria-selected]:focus-visible {
+ outline: 2px solid var(--product-library-divider-tertiary);
+ outline-offset: -2px;
+}
+
+.cell {
+ padding: var(--reactist-spacing-xsmall) var(--reactist-spacing-small);
+ vertical-align: middle;
+}
+
+.cell:first-child {
+ padding-inline-start: var(--reactist-spacing-medium);
+}
+
+.cell:last-child {
+ padding-inline-end: var(--reactist-spacing-medium);
+}
+
+.sortIndicator {
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ justify-content: center;
+ color: var(--product-library-display-secondary-idle-tint);
+}
+
+.sortIndicatorUnsorted {
+ color: var(--product-library-display-tertiary-idle-tint);
+ opacity: 0;
+}
+
+.sortIndicatorDescending {
+ transform: rotate(180deg);
+}
+
+.align-start {
+ text-align: start;
+}
+
+.align-end {
+ text-align: end;
+}
+
+.columnWidth-auto {
+ width: auto;
+}
+
+.columnWidth-content {
+ width: 0;
+}
+
+.columnWidth-1-2 {
+ width: 50%;
+}
+
+.columnWidth-1-3 {
+ width: 33.3333%;
+}
+
+.columnWidth-2-3 {
+ width: 66.6667%;
+}
+
+.columnWidth-1-4 {
+ width: 25%;
+}
+
+.columnWidth-3-4 {
+ width: 75%;
+}
+
+.columnWidth-1-5 {
+ width: 20%;
+}
+
+.columnWidth-2-5 {
+ width: 40%;
+}
+
+.columnWidth-3-5 {
+ width: 60%;
+}
+
+.columnWidth-4-5 {
+ width: 80%;
+}
diff --git a/src/table/table.stories.tsx b/src/table/table.stories.tsx
new file mode 100644
index 000000000..d408d429d
--- /dev/null
+++ b/src/table/table.stories.tsx
@@ -0,0 +1,439 @@
+import * as React from 'react'
+
+import {
+ columnVisibilityFeature,
+ createPaginatedRowModel,
+ createSortedRowModel,
+ flexRender,
+ rowPaginationFeature,
+ rowSortingFeature,
+ sortFn_text,
+ tableFeatures,
+ useTable,
+} from '@tanstack/react-table'
+
+import { Avatar } from '../avatar'
+import { Badge } from '../badge'
+import { Box } from '../box'
+import { Button } from '../button'
+import { Text } from '../text'
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableColumn,
+ TableColumnGroup,
+ TableColumnHeader,
+ TableHeader,
+ TableRow,
+} from './table'
+
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import type { ColumnDef } from '@tanstack/react-table'
+
+type Person = {
+ id: string
+ name: string
+ email: string
+ role: string
+ access: 'Admin' | 'Member' | 'Guest'
+ projects: string
+ activity: string
+}
+
+const people: Person[] = [
+ {
+ id: 'avery-morgan',
+ projects: '1,284',
+ name: 'Avery Morgan',
+ email: 'avery@example.com',
+ role: 'Product designer',
+ access: 'Admin',
+ activity: 'Active now',
+ },
+ {
+ id: 'sam-rivera',
+ projects: '1,037',
+ name: 'Sam Rivera',
+ email: 'sam@example.com',
+ role: 'Frontend engineer',
+ access: 'Member',
+ activity: '8 minutes ago',
+ },
+ {
+ id: 'mika-chen',
+ projects: '9,102',
+ name: 'Mika Chen',
+ email: 'mika@example.com',
+ role: 'Product manager',
+ access: 'Admin',
+ activity: '2 hours ago',
+ },
+ {
+ id: 'noor-patel',
+ projects: '1,116',
+ name: 'Noor Patel',
+ email: 'noor@example.com',
+ role: 'Research lead',
+ access: 'Member',
+ activity: 'Yesterday',
+ },
+ {
+ id: 'theo-williams',
+ projects: '4,411',
+ name: 'Theo Williams',
+ email: 'theo@example.com',
+ role: 'Operations',
+ access: 'Guest',
+ activity: '3 days ago',
+ },
+]
+
+const meta = {
+ title: '📊 Data display/Table',
+ component: Table,
+ parameters: {
+ badges: ['accessible'],
+ figma: {
+ path: 'Web › Components / Todoist › Table',
+ url: 'https://www.figma.com/design/LYlWNzvhMDh907l07mPPQk/Product-Library---Web?node-id=26089-87636',
+ },
+ docs: {
+ description: {
+ component:
+ 'Compound primitives for tabular data. Compose Table with TableColumnGroup, TableHeader, TableColumnHeader, TableBody, TableRow, and TableCell. The consumer owns the data, the sort state, and the selection state; pass aria-selected on a row to make it selectable.',
+ },
+ },
+ },
+} satisfies Meta
+
+export default meta
+
+type Story = StoryObj
+
+function PersonCell({ person }: { person: Person }) {
+ return (
+
+
+
+
+ {person.name}
+
+
+ {person.email}
+
+
+
+ )
+}
+
+function ActivityCell({ person }: { person: Person }) {
+ return (
+
+ )
+}
+
+function getSortAriaLabel(label: string, direction: 'asc' | 'desc' | null) {
+ if (direction === 'asc') return `${label}, sorted ascending. Activate to sort descending.`
+ if (direction === 'desc') return `${label}, sorted descending. Activate to sort ascending.`
+ return `${label}, activate to sort ascending.`
+}
+
+function handleRowKeyDown(
+ event: React.KeyboardEvent,
+ personId: string,
+ onActivate: (personId: string) => void,
+) {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ onActivate(personId)
+ }
+}
+
+export const Default = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+ Person
+
+
+ Role
+
+
+ Projects
+
+
+ Last active
+
+
+
+ {people.map((person) => (
+
+
+
+
+
+
+ {person.role}
+
+
+
+
+ {person.projects}
+
+
+
+
+
+
+ ))}
+
+
+ ),
+} satisfies Story
+
+export const SelectedAndClickableRows = {
+ name: 'Selected and clickable rows',
+ render: function SelectedAndClickableRows() {
+ const [selectedId, setSelectedId] = React.useState(people[1]!.id)
+
+ return (
+
+
+
+
+
+
+
+
+
+ Person
+
+
+ Role
+
+
+ Projects
+
+
+ Last active
+
+
+
+ {people.map((person) => (
+ setSelectedId(person.id)}
+ onKeyDown={(event) => handleRowKeyDown(event, person.id, setSelectedId)}
+ >
+
+
+
+
+
+ {person.role}
+
+
+
+
+ {person.projects}
+
+
+
+
+
+
+ ))}
+
+
+ )
+ },
+} satisfies Story
+
+export const NoHeaderRow = {
+ name: 'No header row',
+ render: () => (
+
+
+
+
+
+
+
+ {people.map((person) => (
+
+
+
+
+
+
+ {person.role}
+
+
+
+
+ {person.activity}
+
+
+
+ ))}
+
+
+ ),
+} satisfies Story
+
+export const MultiLineCells = {
+ name: 'Single and multi-line cells',
+ render: () => (
+
+
+
+
+
+
+
+ Single line
+
+
+ Two line
+
+
+
+
+
+
+ Cell content long enough that it has to truncate with an ellipsis
+
+
+
+ Cell content
+
+ Secondary line
+
+
+
+
+
+ ),
+} satisfies Story
+
+const features = tableFeatures({
+ columnVisibilityFeature,
+ rowPaginationFeature,
+ rowSortingFeature,
+ paginatedRowModel: createPaginatedRowModel(),
+ sortedRowModel: createSortedRowModel(),
+ sortFns: { text: sortFn_text },
+})
+
+const tanStackColumns: ColumnDef[] = [
+ { accessorKey: 'name', header: 'Person', sortFn: 'text' },
+ { accessorKey: 'role', header: 'Role', sortFn: 'text' },
+ { accessorKey: 'access', header: 'Access', enableSorting: false },
+]
+
+export const TanStackIntegration = {
+ name: 'TanStack Table integration',
+ render: function TanStackIntegration() {
+ const table = useTable({
+ features,
+ data: people,
+ columns: tanStackColumns,
+ getRowId: (person) => person.id,
+ initialState: { pagination: { pageIndex: 0, pageSize: 2 } },
+ })
+ const { pageIndex } = table.state.pagination ?? { pageIndex: 0 }
+
+ return (
+
+
+
+
+
+
+
+
+ {table.getHeaderGroups()[0]?.headers.map((header) => {
+ const direction = header.column.getIsSorted() || null
+ const label = String(header.column.columnDef.header)
+
+ return header.column.getCanSort() ? (
+ header.column.toggleSorting()}
+ sortAriaLabel={getSortAriaLabel(label, direction)}
+ >
+
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+
+ ) : (
+
+
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+
+ )
+ })}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+
+ ))}
+
+ ))}
+
+
+
+ table.previousPage()}
+ >
+ Previous
+
+ table.nextPage()}
+ >
+ Next
+
+
+ Page {pageIndex + 1} of {table.getPageCount()}
+
+
+
+ )
+ },
+} satisfies Story
diff --git a/src/table/table.test.tsx b/src/table/table.test.tsx
new file mode 100644
index 000000000..4a4894d93
--- /dev/null
+++ b/src/table/table.test.tsx
@@ -0,0 +1,200 @@
+import * as React from 'react'
+
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'jest-axe'
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableColumn,
+ TableColumnGroup,
+ TableColumnHeader,
+ TableHeader,
+ TableRow,
+} from './index'
+
+function BasicTable({ withHeader = true }: { withHeader?: boolean }) {
+ return (
+
+ {withHeader ? (
+
+ Person
+ Role
+
+ ) : null}
+
+
+ Avery Morgan
+ Product designer
+
+
+
+ )
+}
+
+describe('Table primitives', () => {
+ it('renders native table semantics from composed children', () => {
+ render( )
+ expect(screen.getByRole('table', { name: 'People' })).toBeInTheDocument()
+ expect(screen.getAllByRole('columnheader')).toHaveLength(2)
+ expect(screen.getByRole('cell', { name: 'Avery Morgan' })).toBeInTheDocument()
+ })
+
+ it('renders no thead when TableHeader is omitted', () => {
+ const { container } = render( )
+ expect(container.querySelector('thead')).toBeNull()
+ expect(screen.queryAllByRole('columnheader')).toHaveLength(0)
+ expect(screen.getByRole('cell', { name: 'Avery Morgan' })).toBeInTheDocument()
+ })
+
+ it('forwards refs on every primitive', () => {
+ const refs = {
+ table: React.createRef(),
+ body: React.createRef(),
+ row: React.createRef(),
+ cell: React.createRef(),
+ }
+ render(
+ ,
+ )
+ expect(refs.table.current?.tagName).toBe('TABLE')
+ expect(refs.body.current?.tagName).toBe('TBODY')
+ expect(refs.row.current?.tagName).toBe('TR')
+ expect(refs.cell.current?.tagName).toBe('TD')
+ })
+
+ it('marks only rows with aria-selected as selectable', () => {
+ render(
+
+
+
+ Selectable
+
+
+ Selected
+
+
+ Plain
+
+
+
,
+ )
+ const rows = screen.getAllByRole('row')
+ expect(rows[0]).toHaveAttribute('aria-selected', 'false')
+ expect(rows[1]).toHaveAttribute('aria-selected', 'true')
+ expect(rows[2]).not.toHaveAttribute('aria-selected')
+ })
+
+ it('maps the column width prop onto the column definition', () => {
+ const { container } = render(
+
+
+
+
+
+
+
+ Avery Morgan
+ Product designer
+
+
+
,
+ )
+ const [sized, defaulted] = Array.from(container.querySelectorAll('col'))
+ expect(sized?.className).toContain('columnWidth-2-5')
+ expect(defaulted?.className).toContain('columnWidth-auto')
+ })
+
+ it('has no automated accessibility violations', async () => {
+ const { container } = render( )
+ expect(await axe(container)).toHaveNoViolations()
+ })
+})
+
+describe('TableColumnHeader sorting', () => {
+ function SortableHeader({
+ sortDirection = null,
+ onSort = jest.fn(),
+ }: {
+ sortDirection?: 'asc' | 'desc' | null
+ onSort?: () => void
+ }) {
+ return (
+
+ )
+ }
+
+ it('omits aria-sort on a non-sortable header', () => {
+ render(
+ ,
+ )
+ expect(screen.getByRole('columnheader')).not.toHaveAttribute('aria-sort')
+ })
+
+ it.each([
+ ['asc' as const, 'ascending'],
+ ['desc' as const, 'descending'],
+ ])('maps sortDirection %s to aria-sort %s', (direction, expected) => {
+ render( )
+ expect(screen.getByRole('columnheader')).toHaveAttribute('aria-sort', expected)
+ })
+
+ it('omits aria-sort on a sortable header that is not sorted', () => {
+ render( )
+ expect(screen.getByRole('columnheader')).not.toHaveAttribute('aria-sort')
+ })
+
+ it('fires onSort exactly once per activation', async () => {
+ const onSort = jest.fn()
+ const user = userEvent.setup()
+ render( )
+ const button = screen.getByRole('button', { name: /Person/ })
+
+ await user.click(button)
+ expect(onSort).toHaveBeenCalledTimes(1)
+
+ button.focus()
+ await user.keyboard('{Enter}')
+ expect(onSort).toHaveBeenCalledTimes(2)
+
+ await user.keyboard(' ')
+ expect(onSort).toHaveBeenCalledTimes(3)
+ })
+ it('renders the bundled sort icon', () => {
+ const { container } = render( )
+ expect(container.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it.each([
+ ['asc' as const, false],
+ ['desc' as const, true],
+ [null, true],
+ ])('rotates the indicator for sortDirection %s: %s', (direction, rotated) => {
+ const { container } = render( )
+ const indicator = container.querySelector('th span[aria-hidden="true"]')
+ expect(indicator?.className.includes('sortIndicatorDescending')).toBe(rotated)
+ })
+})
diff --git a/src/table/table.tsx b/src/table/table.tsx
new file mode 100644
index 000000000..8cdb621aa
--- /dev/null
+++ b/src/table/table.tsx
@@ -0,0 +1,269 @@
+import * as React from 'react'
+
+import classNames from 'classnames'
+
+import { Box } from '../box'
+
+import { SortIndicator } from './sort-indicator'
+
+import styles from './table.module.css'
+
+import type { ObfuscatedClassName } from '../utils/common-types'
+
+type TableProps = Omit, 'className' | 'border'> &
+ ObfuscatedClassName
+
+type TableHeaderProps = Omit, 'className'> &
+ ObfuscatedClassName
+
+type TableBodyProps = Omit, 'className'> &
+ ObfuscatedClassName
+
+type TableRowProps = Omit, 'className'> &
+ ObfuscatedClassName
+
+type TableCellProps = Omit, 'align' | 'className'> &
+ ObfuscatedClassName & {
+ /** Horizontal alignment of the cell content. */
+ align?: 'start' | 'end'
+ }
+
+type TableColumnWidth =
+ | 'auto'
+ | 'content'
+ | '1/2'
+ | '1/3'
+ | '2/3'
+ | '1/4'
+ | '3/4'
+ | '1/5'
+ | '2/5'
+ | '3/5'
+ | '4/5'
+
+type TableColumnGroupProps = Omit, 'className'> &
+ ObfuscatedClassName
+
+type TableColumnProps = Omit, 'className' | 'width'> &
+ ObfuscatedClassName & {
+ /** Width of this column, as a fraction of the table. */
+ width?: TableColumnWidth
+ }
+
+type SortableProps =
+ | {
+ /** Renders the sort control and makes the header activatable. */
+ sortable: true
+
+ /** Direction for this column, or null when it is sortable but not sorted. */
+ sortDirection: 'asc' | 'desc' | null
+
+ /** Called when the sort control is activated. */
+ onSort: () => void
+
+ /** Complete localized label for the sort button. */
+ sortAriaLabel: string
+ }
+ | {
+ sortable?: false
+ sortDirection?: never
+ onSort?: never
+ sortAriaLabel?: never
+ }
+
+type TableColumnHeaderProps = Omit<
+ React.ThHTMLAttributes,
+ 'align' | 'className' | 'onSort'
+> &
+ ObfuscatedClassName &
+ SortableProps & {
+ /** Horizontal alignment of the header content. */
+ align?: 'start' | 'end'
+ }
+
+function ariaSortFor(sortDirection: 'asc' | 'desc') {
+ return sortDirection === 'asc' ? 'ascending' : 'descending'
+}
+
+/**
+ * Tabular data in native table markup, composed from:
+ * * {@link TableColumnGroup}
+ * * {@link TableColumn}
+ * * {@link TableHeader}
+ * * {@link TableColumnHeader}
+ * * {@link TableBody}
+ * * {@link TableRow}
+ * * {@link TableCell}
+ */
+const Table = React.forwardRef(function Table(
+ { exceptionallySetClassName, ...tableProps },
+ ref,
+) {
+ return (
+
+ )
+})
+
+/** Column definitions for the table. Render it as the first child of {@link Table}. */
+const TableColumnGroup = React.forwardRef(
+ function TableColumnGroup({ exceptionallySetClassName, ...groupProps }, ref) {
+ return (
+
+ )
+ },
+)
+
+/** A single column definition. */
+const TableColumn = React.forwardRef(function TableColumn(
+ { width = 'auto', exceptionallySetClassName, ...columnProps },
+ ref,
+) {
+ return (
+
+ )
+})
+
+/** A table header row wrapper. Omit it for a table with no header. */
+const TableHeader = React.forwardRef(
+ function TableHeader({ children, exceptionallySetClassName, ...headerProps }, ref) {
+ return (
+
+ {children}
+
+ )
+ },
+)
+
+/** A table body that wraps its rows. */
+const TableBody = React.forwardRef(function TableBody(
+ { exceptionallySetClassName, ...bodyProps },
+ ref,
+) {
+ return
+})
+
+/** A table row. Pass `aria-selected` to make it selectable. */
+const TableRow = React.forwardRef(function TableRow(
+ { exceptionallySetClassName, ...rowProps },
+ ref,
+) {
+ return (
+
+ )
+})
+
+/** A table data cell. */
+const TableCell = React.forwardRef(function TableCell(
+ { align = 'start', children, exceptionallySetClassName, ...cellProps },
+ ref,
+) {
+ return (
+
+ {children}
+
+ )
+})
+
+/** A table column header, optionally sortable. */
+const TableColumnHeader = React.forwardRef(
+ function TableColumnHeader(
+ {
+ sortable,
+ sortDirection = null,
+ onSort,
+ sortAriaLabel,
+ align = 'start',
+ children,
+ exceptionallySetClassName,
+ ...headerProps
+ },
+ ref,
+ ) {
+ const label = {children}
+ const indicatorDirection = sortDirection ?? 'desc'
+ const descendingClass = styles.sortIndicatorDescending ?? ''
+ const unsortedClass = styles.sortIndicatorUnsorted ?? ''
+
+ return (
+
+ {sortable ? (
+
+ {label}
+
+
+
+
+ ) : (
+ label
+ )}
+
+ )
+ },
+)
+
+export {
+ Table,
+ TableBody,
+ TableCell,
+ TableColumn,
+ TableColumnGroup,
+ TableColumnHeader,
+ TableHeader,
+ TableRow,
+}
+export type {
+ TableBodyProps,
+ TableCellProps,
+ TableColumnGroupProps,
+ TableColumnHeaderProps,
+ TableColumnProps,
+ TableColumnWidth,
+ TableHeaderProps,
+ TableProps,
+ TableRowProps,
+}
diff --git a/stories/components/styles/story.css b/stories/components/styles/story.css
index 6e08f70a8..fdfef8779 100644
--- a/stories/components/styles/story.css
+++ b/stories/components/styles/story.css
@@ -1,4 +1,5 @@
body {
+ color: var(--product-library-display-primary-idle-tint);
font-family: var(--reactist-font-family);
}