The Component Workshop: Reflex's 100 Library Components
·
Course overview
Layout
·
Box
·
Flex
·
Stack
·
Grid
·
Container
·
Section
·
Center
·
Spacer
·
Separator
·
Card
·
Inset
·
Aspect Ratio
·
Fragment
Typography
·
Text
·
Heading
·
Link
·
Code
·
Markdown
·
Blockquote
·
Quote
·
Strong
·
Em
·
Kbd
Forms
·
Button
·
Input
·
Text Area
·
Checkbox
·
Switch
·
Radio Group
·
Select
·
Slider
·
Form
·
Upload
Data display
·
Badge
·
Avatar
·
Icon
·
Callout
·
List
·
Data List
·
Code Block
·
Progress
·
Spinner
·
Scroll Area
·
Moment
Disclosure
·
Tabs
·
Accordion
·
Segmented Control
Overlay
·
Tooltip
·
Popover
·
Hover Card
·
Dialog
·
Alert Dialog
·
Drawer
·
Dropdown Menu
·
Context Menu
·
Toast
Dynamic rendering
·
Cond
·
Match
·
Foreach
·
Auto Scroll
Media
·
Image
·
Audio
·
Video
Tables and data grids
·
Table
·
Data Table
·
Data Editor
Charts
·
Area Chart
·
Bar Chart
·
Line Chart
·
Composed Chart
·
Pie Chart
·
Radar Chart
·
Radial Bar Chart
·
Scatter Chart
·
Funnel Chart
·
Treemap
·
Sankey Chart
·
Error Bar
Chart parts
·
Axis
·
Cartesian Grid
·
Chart Tooltip
·
Legend
·
Label
·
Reference
·
Brush
Other chart libraries
·
Plotly
·
Pyplot
HTML elements
·
HTML
·
HTML Text
·
HTML Layout
·
HTML Forms
·
HTML Media
·
HTML Tables
·
SVG
Other tools
·
Theme
·
Skeleton
·
Clipboard
·
Html Embed
·
Script
·
Memo
rx.table.rootrx.table.headerrx.table.rowrx.table.column_header_cellrx.table.bodyrx.table.cellrx.table.row_header_cellOfficial docs
Workshop order
Build a table of team members for the team management screen. Each row has an avatar, a name link, an email and a role badge.
rx.table is a table you assemble yourself from header rows, rows and cells. Any component can go in a cell, such as an avatar, a link or a button, which makes it a good fit for lists whose look you want to control. If you need a table with sorting, search and pagination built in, Data Table may get you there faster.
table.root: the whole table.table.header: the head section (<thead>). Inside it go a table.row and table.column_header_cells (column title cells).table.body: the body (<tbody>). Inside it go table.rows.table.row: a single row.table.row_header_cell: the first cell that labels its row (a row title shown in bold).table.cell: a regular cell.rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Full name"),
rx.table.column_header_cell("Email"),
rx.table.column_header_cell("Group"),
),
),
rx.table.body(
rx.table.row(
rx.table.row_header_cell("Danilo Sousa"),
rx.table.cell("danilo@example.com"),
rx.table.cell("Developer"),
),
rx.table.row(
rx.table.row_header_cell("Zahra Ambessa"),
rx.table.cell("zahra@example.com"),
rx.table.cell("Admin"),
),
rx.table.row(
rx.table.row_header_cell("Jasper Eriks"),
rx.table.cell("jasper@example.com"),
rx.table.cell("Developer"),
),
),
width="100%",
)It is best to fit the table to its container with width. If there are so many columns that it overflows, wrap it as in rx.box(rx.table.root(...), overflow_x="auto", width="100%") so only the table scrolls sideways, not the page.
variant and size go on table.root; justify, the horizontal alignment of a cell's content, goes on each cell. Number columns are usually aligned to the right (end).
Try the props
variant
size
justify
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Item"),
rx.table.column_header_cell("Qty", justify="end"),
rx.table.column_header_cell("Price", justify="end"),
),
),
rx.table.body(
rx.table.row(
rx.table.row_header_cell("Coffee beans"),
rx.table.cell("2", justify="end"),
rx.table.cell("$24.00", justify="end"),
),
rx.table.row(
rx.table.row_header_cell("Paper filters"),
rx.table.cell("1", justify="end"),
rx.table.cell("$5.50", justify="end"),
),
),
variant="ghost",
size="2",
width="100%",
)| Part | Prop | Values | Use |
|---|---|---|---|
root | variant | "surface" "ghost" | A table with border and background, or one with lines only |
root | size | "1" "2" "3" | Cell padding and font size |
root | align · summary | "left" "center" "right" · string | Table alignment, table summary (HTML attributes) |
row | align | "start" "center" "end" "baseline" | Vertical alignment of the cells in the row |
column_header_cell · cell · row_header_cell | justify | "start" "center" "end" | Horizontal alignment of the cell's content |
| All three cells | col_span · row_span | int | A cell spanning several columns or rows |
| All three cells | min_width · max_width | CSS length | Minimum and maximum column width |
cell · row_header_cell | p px py pt pr pb pl | "0" ~ "9" | Cell padding (theme spacing steps) |
column_header_cell · row_header_cell | scope | "col" "row" etc. | What the header cell refers to (HTML attribute) |
header and body have no props of their own. A cell's align is the old HTML align attribute; for Radix alignment, use justify.
col_span=2 makes one cell take up two columns, and row_span=2 two rows. A row's align sets the vertical position of its cells.
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Day"),
rx.table.column_header_cell("Morning"),
rx.table.column_header_cell("Afternoon"),
),
),
rx.table.body(
rx.table.row(
rx.table.row_header_cell("Mon"),
rx.table.cell("Workshop", col_span=2, justify="center"),
),
rx.table.row(
rx.table.row_header_cell("Tue"),
rx.table.cell("Review", row_span=2),
rx.table.cell("Demo"),
align="center",
),
rx.table.row(
rx.table.row_header_cell("Wed"),
rx.table.cell("Q&A"),
),
),
variant="surface",
width="100%",
)If the table's content has to change, keep the data in State and draw the rows with rx.foreach. A separate function that draws one row keeps the table code short. Row data can also be dicts or dataclasses instead of lists (with a dataclass you read fields by name, as in person.email).
class TableForEachState(rx.State):
people: list[list[str]] = [
["Danilo Sousa", "danilo@example.com", "Developer"],
["Zahra Ambessa", "zahra@example.com", "Admin"],
["Jasper Eriks", "jasper@example.com", "Developer"],
]
def table_person_row(person: list):
return rx.table.row(
rx.table.cell(person[0]),
rx.table.cell(person[1]),
rx.table.cell(person[2]),
)
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Full name"),
rx.table.column_header_cell("Email"),
rx.table.column_header_cell("Group"),
),
),
rx.table.body(rx.foreach(TableForEachState.people, table_person_row)),
width="100%",
)For data in memory, sort and search with Python's sorted() and list comprehensions. Keep the original in a backend-only variable whose name starts with an underscore, like _people, so it is never sent to the browser, and build the visible list as the computed var current_people. With cache=True it is recomputed only when the sort value or the search term changes. The search box's on_change gets .debounce(300), so the event is sent when typing pauses rather than on every keystroke.
class TableSearchState(rx.State):
_people: list[dict[str, str]] = [
{"full_name": "Danilo Sousa", "email": "danilo@example.com", "group": "Developer"},
{"full_name": "Zahra Ambessa", "email": "zahra@example.com", "group": "Admin"},
{"full_name": "Jasper Eriks", "email": "zjasper@example.com", "group": "B-Developer"},
{"full_name": "Alice Brown", "email": "alice@example.com", "group": "Admin"},
]
sort_value: str = ""
search_value: str = ""
@rx.event
def set_sort_value(self, value: str):
self.sort_value = value
@rx.event
def set_search_value(self, value: str):
self.search_value = value
@rx.var(cache=True)
def current_people(self) -> list[dict[str, str]]:
people = self._people
if self.sort_value != "":
people = sorted(people, key=lambda p: p[self.sort_value].lower())
if self.search_value != "":
term = self.search_value.lower()
people = [p for p in people if any(term in v.lower() for v in p.values())]
return people
def table_search_row(person: dict):
return rx.table.row(
rx.table.cell(person["full_name"]),
rx.table.cell(person["email"]),
rx.table.cell(person["group"]),
)
rx.vstack(
rx.flex(
rx.select(
["full_name", "email", "group"],
placeholder="Sort By: full_name",
on_change=TableSearchState.set_sort_value,
),
rx.input(
placeholder="Search here...",
on_change=TableSearchState.set_search_value.debounce(300),
),
spacing="3",
wrap="wrap",
),
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Full name"),
rx.table.column_header_cell("Email"),
rx.table.column_header_cell("Group"),
),
),
rx.table.body(rx.foreach(TableSearchState.current_people, table_search_row)),
width="100%",
),
width="100%",
)If the data lives in a database, build the same screen with an SQL query. Trimmed down, the official docs example looks like this (it does not run on this page). ilike is a case-insensitive partial match, and the % on either side of the search term means "any characters".
from sqlmodel import asc, or_, select
class Customer(rx.Model, table=True):
name: str
email: str
phone: str
address: str
class DatabaseTableState(rx.State):
users: list[Customer] = []
sort_value: str = ""
search_value: str = ""
@rx.event
def load_entries(self):
with rx.session() as session:
query = select(Customer)
if self.search_value:
pattern = f"%{self.search_value.lower()}%"
query = query.where(or_(Customer.name.ilike(pattern), Customer.email.ilike(pattern)))
if self.sort_value:
query = query.order_by(asc(getattr(Customer, self.sort_value)))
self.users = session.exec(query).all()The more data there is, the better it is to filter on the database side; for small data, or data fetched from an API, filtering in memory is simpler.
To show only a few rows at a time, keep offset (rows to skip) and limit (rows to show) in State and put just that slice into users. on_mount on table.root fills in the first data. With a database, use query.offset(self.offset).limit(self.limit) instead of slicing.
Page 1 / 1
class TablePageState(rx.State):
_customers: list[dict[str, str]] = [
{"name": "John Doe", "email": "john@example.com", "phone": "555-1234"},
{"name": "Jane Smith", "email": "jane@example.com", "phone": "555-5678"},
{"name": "Bob Johnson", "email": "bob@example.com", "phone": "555-9012"},
{"name": "Alice Brown", "email": "alice@example.com", "phone": "555-3456"},
{"name": "Charlie Wilson", "email": "charlie@example.com", "phone": "555-7890"},
{"name": "Emily Davis", "email": "emily@example.com", "phone": "555-2345"},
{"name": "Frank Miller", "email": "frank@example.com", "phone": "555-6789"},
]
users: list[dict[str, str]] = []
total_items: int = 0
offset: int = 0
limit: int = 3
@rx.var
def page_number(self) -> int:
return self.offset // self.limit + 1
@rx.var
def total_pages(self) -> int:
return max(1, -(-self.total_items // self.limit))
@rx.event
def load_entries(self):
self.total_items = len(self._customers)
self.users = self._customers[self.offset : self.offset + self.limit]
@rx.event
def prev_page(self):
self.offset = max(self.offset - self.limit, 0)
self.load_entries()
@rx.event
def next_page(self):
if self.offset + self.limit < self.total_items:
self.offset += self.limit
self.load_entries()
def table_page_row(user: dict):
return rx.table.row(
rx.table.cell(user["name"]),
rx.table.cell(user["email"]),
rx.table.cell(user["phone"]),
)
rx.vstack(
rx.hstack(
rx.button("Prev", on_click=TablePageState.prev_page, variant="soft"),
rx.text("Page ", TablePageState.page_number, " / ", TablePageState.total_pages),
rx.button("Next", on_click=TablePageState.next_page, variant="soft"),
align="center",
),
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Name"),
rx.table.column_header_cell("Email"),
rx.table.column_header_cell("Phone"),
),
),
rx.table.body(rx.foreach(TablePageState.users, table_page_row)),
on_mount=TablePageState.load_entries,
width="100%",
),
width="100%",
)If one page has several paginated tables, each table needs its own offset and limit. When the search term or sort order changes, reset offset to 0 so you don't stay on a page past the end of the shrunken results. To reuse the same table several times, rx.ComponentState can give each table its own State.
rx.download lets users download the table's data as a file. For JSON, the UI passes the State value directly; values that are not strings are saved as JSON text. For CSV, the handler builds the text with the csv module and returns rx.download. Either way, the file is received by the user's browser and nothing is saved on the server.
import csv
import io
import json
class TableDownloadState(rx.State):
users: list[dict[str, str]] = [
{"name": "John Doe", "email": "john@example.com", "phone": "555-1234"},
{"name": "Jane Smith", "email": "jane@example.com", "phone": "555-5678"},
{"name": "Bob Johnson", "email": "bob@example.com", "phone": "555-9012"},
]
@rx.event
def download_csv_data(self):
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=["name", "email", "phone"])
writer.writeheader()
writer.writerows(self.users)
return rx.download(data=output.getvalue(), filename="data.csv")
def table_download_row(user: dict):
return rx.table.row(
rx.table.cell(user["name"]),
rx.table.cell(user["email"]),
rx.table.cell(user["phone"]),
)
rx.vstack(
rx.table.root(
rx.table.header(
rx.table.row(
rx.table.column_header_cell("Name"),
rx.table.column_header_cell("Email"),
rx.table.column_header_cell("Phone"),
),
),
rx.table.body(rx.foreach(TableDownloadState.users, table_download_row)),
width="100%",
),
rx.hstack(
rx.button("Download as JSON", on_click=rx.download(data=TableDownloadState.users, filename="data.json")),
rx.button("Download as CSV", on_click=TableDownloadState.download_csv_data),
spacing="3",
wrap="wrap",
),
width="100%",
spacing="4",
)A team list with only a body and no header row. The first cell holds the avatar, the row title cell the name link, and the last cell the role badge; every row gets align="center" so the avatar and text line up vertically. The table is wrapped in an overflow_x="auto" box so the page doesn't overflow on narrow screens.
Invite and manage your team members
rx.flex(
rx.heading("Your Team", as_="h2", size="5"),
rx.text("Invite and manage your team members"),
rx.flex(
rx.input(placeholder="Email Address"),
rx.button("Invite"),
spacing="2",
),
rx.box(
rx.table.root(
rx.table.body(
rx.table.row(
rx.table.cell(rx.avatar(fallback="DS")),
rx.table.row_header_cell(rx.link("Danilo Sousa")),
rx.table.cell("danilo@example.com"),
rx.table.cell(rx.badge("Developer")),
align="center",
),
rx.table.row(
rx.table.cell(rx.avatar(fallback="ZA")),
rx.table.row_header_cell(rx.link("Zahra Ambessa")),
rx.table.cell("zahra@example.com"),
rx.table.cell(rx.badge("Admin", color_scheme="orange")),
align="center",
),
rx.table.row(
rx.table.cell(rx.avatar(fallback="JE")),
rx.table.row_header_cell(rx.link("Jasper Eriksson")),
rx.table.cell("jasper@example.com"),
rx.table.cell(rx.badge("Developer")),
align="center",
),
),
width="100%",
),
overflow_x="auto",
width="100%",
),
width="100%",
direction="column",
spacing="2",
)Workshop notes
root > header(row > column_header_cell) + body(row > row_header_cell · cell).
variant (surface/ghost) · size on root; justify · col_span · row_span on cells; align on rows.
Changing data: State + rx.foreach. Keep the original in a _backend var and the visible list in a computed var.
Sorting, search and pagination are up to you. If you need a table with all of it built in, use rx.data_table.
Wrap wide tables in an overflow_x="auto" box so only the table scrolls.
Get news about new lessons
We'll email you when this course gets new lessons or a follow-up course opens.
R
ReflexKR
Learn Python and Reflex by doing. Every example was run and verified on Reflex 0.10.0.
ReflexKR is free, with no banner ads. If it helped you, you can support it with a coffee. ☕ Buy us a coffee
Affiliate disclosure© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
Questions & answers
Ask anything about this lesson. Questions from every language version of this page appear together.