Writing Component Pages
Overview
A component page is a directory under components/{inputs,outputs,display-messages,layout}/<name>/
containing an index.qmd plus a set of app-*.py example files. The index.qmd
front matter is data-driven: EJS templates in components/_partials/ turn it into
the rendered page (gallery card, live preview, tabbed Core/Express code, variations,
kitchen sink). Get the file names and front-matter keys right and the build does the rest.
Reference implementations to copy from:
- Minimal input:
components/inputs/action-button/(example + kitchen sink) - Rich output with variations:
components/outputs/data-grid/
Anatomy of a component directory
components/inputs/<name>/
index.qmd # required — page content + front matter
app-preview.py # required — gallery-card thumbnail app (Core style)
app-detail-preview.py # the live "Preview" app shown atop the example
app-core.py # Core-mode example (shown in "Core" tab)
app-express.py # Express-mode example (shown in "Express" tab)
app-kitchensink-core.py # optional — every-parameter demo (Core)
app-kitchensink-express.py # optional — every-parameter demo (Express)
app-variation-<slug>-core.py # optional — one set per variation
app-variation-<slug>-express.py
app-variation-<slug>-preview.py # dedicated live Preview app for that variation
thumbnail.png # optional — static image for the kitchen-sink block
File-role cheat sheet:
| File | Rendered as | Style |
|---|---|---|
app-preview.py |
Gallery-list card (via appPreview: + make docs-static-previews) |
Core, module-level app_ui, viewport-filling (vh-100 d-flex justify-content-center align-items-center) |
app-detail-preview.py |
Live standalone app at top of #example (the "Preview" tab) |
Core, module-level app_ui; may use ## file: app.py multi-file marker |
app-core.py / app-express.py |
Static code blocks in the Core/Express tabs, each with an "Open in editor" shinylive link | Idiomatic Core / Express; mark the key line with a trailing # << comment |
app-variation-*-{core,express}.py |
One entry in the #variations block |
Same as core/express |
app-kitchensink-*.py |
Linked from the #kitchen-sink block |
All parameters exercised |
Every example app file MUST be named app.py or app-<name>.py. This is the only
naming the tooling recognizes: the shinylive-link generator, the static-preview renderer,
and the smoke-test collector (components/test_examples_smoke.py, which auto-discovers and
launches every app.py/app-*.py under components/) all key off this convention. A
differently named .py file is silently ignored — never rendered, never linked, never
tested. Companion files inside a multi-file app use ## file: <name>.py markers inside
the app-*.py, not separate top-level names. See the testing-example-apps skill for how
those apps get smoke-tested.
Why app-preview.py and app-detail-preview.py must be Core style:
components/make-static-previews.py imports the module and reads app_ui. Express
apps have no module-level app_ui, so the static renderer raises app_ui not found.
index.qmd front matter
Front matter is YAML between --- fences. The body below it holds only the placeholder
blocks and prose. Structure (see data-grid for all of it):
---
title: Task Button
sidebar: components
appPreview:
file: components/inputs/task-button/app-preview.py # repo-root-relative path
static: true # render to static HTML card
listing:
- id: example
template: ../../_partials/components-detail-example.ejs
template-params:
dir: components/inputs/task-button/
contents:
- title: Preview # rendered as a LIVE app; gets NO shinylive link
file: app-detail-preview.py
height: 200
- title: Express # static code + shinylive editor link
file: app-express.py
shinylive: PLACEHOLDER # regenerated by `make docs-update-shinylive-links`
- title: Core
file: app-core.py
shinylive: PLACEHOLDER
- id: relevant-functions
template: ../../_partials/components-detail-relevant-functions.ejs
contents:
- title: ui.input_task_button
href: https://shiny.posit.co/py/api/ui.input_task_button.html
signature: ui.input_task_button(id, label, ...)
- id: variations # optional
template: ../../_partials/components-variations.ejs
template-params:
dir: components/inputs/task-button/
contents:
- title: Reset the button
description: Call `ui.update_task_button()` to reset it.
apps:
- title: Preview # dedicated preview file, no shinylive key
file: app-variation-reset-preview.py
height: 350
- title: Express
file: app-variation-reset-express.py
shinylive: PLACEHOLDER
- title: Core
file: app-variation-reset-core.py
shinylive: PLACEHOLDER
- id: kitchen-sink # optional
template: ../../_partials/components-detail-kitchen-sink.ejs
contents:
- title: Task Button
core: https://shinylive.io/py/editor/#... # full editor URL, hand-added
express: https://shinylive.io/py/editor/#...
image: thumbnail.png
---
:::{#example}
:::
:::{#relevant-functions}
:::
## Details
Prose explaining what the component is and how to use it.
:::{#variations}
:::
:::{#kitchen-sink}
:::
See also: [Action Button](../action-button/index.qmd)
Key rules:
sidebar: componentsis required on every page.- Prefer
appPreview: { file, static: true }(a rendered screenshot ofapp-preview.py) over the inlinepreview:HTML-string key. Only ~8 pages usepreview:; reserve it for components that genuinely can't be statically rendered. Exception: components whose card effect only appears on interaction (toasts, popovers, notifications, modals — most ofdisplay-messages/) must omitstatic: trueso the card runs LIVE via shinylive; a static snapshot of a "Show toast" button is a card whose button does nothing. Every existing display-messages card is live. - The
#examplePreview tab (and each variation's Preview) runs LIVE via shinylive; the link generator deliberately skipstitle: Preview, so it needs noshinylive:key. Every non-Preview app must carry ashinylive:key or the generator warns and skips it. - Point the Preview tab at a dedicated
app-detail-preview.py, not atapp-core.pyorapp-preview.py. (Reusing core/preview is the most common shortcut in review.) relevant-functionshrefandsignatureare generated, not hand-written. Add the entry with just atitle:(e.g.ui.input_text_area) plus placeholderhref/signature, then runmake docs-update-relevant-functions— it reads the quartodoc-generatedapi/**pages and rewrites both fields in place, annotation-free. The generatedhrefform ishttps://shiny.posit.co/py/api/core/<page-stem>.html#shiny.<qualified-name>(components/_relevant_functions.py:23holds the base URL;_href()at:97-98builds it); for a method,<page-stem>is the class's page and the anchor isshiny.<Class>.<method>. Thetest-docsworkflow'srelevant-functionsjob regenerates these on every PR and fails when the committed values differ, so never hand-edit them.- Third-party functions (e.g. Great Tables) have no
api/**page, so the generator can't resolve them: link the package's own documentation and register the title in the matching skip set incomponents/_relevant_functions.py(_EXTERNALfor third-party,_NO_API_PAGEfor ashiny.uiexport missing from py-shiny's quartodoc config) so strict regeneration still passes. - List the component's mutator functions in
relevant-functions, not just its constructor. Every server-sideupdate_<name>/insert_<name>/remove_<name>that pairs with the component belongs here as its owntitle/href/signatureentry (e.g. the Text Area page lists bothui.input_text_areaandui.update_text_area; Accordion listsui.accordionplusupdate_accordion,update_accordion_panel,insert_accordion_panel,remove_accordion_panel). This is enforced:components/test_ui_api_has_page.pychecks that every publicuiexport is documented by some page'srelevant-functionsblock, and mutators are expected to be found on their base component's page rather than opted out. Add thetitle:for each and letmake docs-update-relevant-functionsfill in the rest. Note that this test is satisfied by atitle:string alone — it proves the function is listed, not that it is actually explained or demonstrated. - Each variation's Preview app should be its own
app-variation-<slug>-preview.py(same rule as the top-level Preview — noshinylive:key), not a reuse of its-core.py.
Shinylive links — never hand-write them
Run make docs-update-shinylive-links (script: components/update-shinylive-links.py).
It encodes each app's source into the shinylive: value in place. You only need a
shinylive: key present (a placeholder value is fine); the script overwrites it.
Always re-run make docs-update-shinylive-links after editing, adding, or removing
any app-*.py file (or its resources:). The shinylive: values are an encoding of
the app source, so any change to the source makes the committed link stale. This is not
optional: the test-docs workflow's shinylive-links job regenerates the links on
every PR and fails the build if the committed links differ. Regenerate and commit the
updated index.qmd files as part of the same change — do not leave it for later.
To rebuild just the page(s) you touched (faster than rewriting all of them), pass
FILES= — it accepts component dirs, index.qmd paths, or any file inside a component
dir (e.g. the app-*.py you just edited), which it resolves to the owning index.qmd:
make docs-update-shinylive-links FILES="components/inputs/<name>/"
make docs-update-shinylive-links FILES="components/inputs/<name>/app-core.py components/inputs/<name>/app-express.py"
With no FILES, it rewrites every component page (what CI does).
- Multi-file apps: if an app needs extra files, split with
## file: app.pymarkers inside the.py, and list companion assets under aresources:key in the front-matter entry. The link checker warnsMultiple files in appwhen a bundle has1 file but no
resources:. - Kitchen-sink
core/expressURLs are full.../editor/#h=0&code=...links added by hand (they open the full editor), not generated by the script.
Static preview cards
make docs-static-previews (script: components/make-static-previews.py) walks every
index.qmd, and for each with appPreview.static: true renders appPreview.file to
components/static/.../*.html. This is why app-preview.py must expose a module-level
app_ui and should be laid out to look good centered in a small card.
Register in the sidebar
Add the page to _quarto.yml under the matching contents: list, kept alphabetical:
- components/inputs/<name>/index.qmd
The four sections are inputs, outputs, display-messages, layout.
Build & verify
make docs # = components-shinylive-links + components-relevant-functions + components-static-previews
make serve # live preview; open the new page
Confirm: gallery card renders, Preview tab runs live, Core/Express tabs show code with working "Open in editor" links, variations/kitchen sink render, and the sidebar links.
Exercise every interactive control in every demo app
Compiling / importing an app only proves it starts — it does NOT prove the buttons
work. Reactive handlers (@reactive.event) only run on interaction, so a broken
ui.insert_*/ui.update_* call raises at click time and is invisible until then. You
MUST drive each interactive app-*.py in a real browser and confirm every control does
what its demo claims.
For each app that has a desired behavior or interactive control — a button, switch, slider, clickable panel/header, or even the mere appearance of an icon or rendered element (not everything worth confirming is interactive) — run it and exercise / observe it:
# Run one app (use the repo venv's shiny)
.venv/bin/shiny run --port 8765 components/<section>/<name>/app-variation-<slug>-express.py \
> /tmp/app.log 2>&1 &
# wait for "Uvicorn running", then drive it in a browser (Playwright MCP) and assert
# the visible result changed the way the demo promises; finally check the app log has
# no traceback.
In the browser, for each control assert the observable effect, not just the absence of
a crash — e.g. "Add panel" adds a panel to the accordion, the switch flips the panel
titles, the slider updates the output text. Then grep the server log for
Traceback/Error. Do this for both the Core and Express versions and every
variation's live Preview app — bugs frequently live in only one mode (see below).
Save the confirmation as a test — don't let it evaporate. A one-off manual browser
drive proves the control works today; it does nothing to stop a future edit from
re-breaking it. Once you've confirmed the observable effect by hand, capture the same
interaction as a py-shiny Playwright test so it runs in CI on every PR. See the
testing-example-apps skill for the full workflow —
in short, add components/<section>/<name>/test_<name>.py next to the app files and drive
the primary Core AND Express apps with shiny.playwright.controller, asserting the same
observable effect you just checked by hand:
from playwright.sync_api import Page
from shiny.playwright import controller
from shiny.run import ShinyAppProc
def test_core_interaction(page: Page, core_app: ShinyAppProc) -> None:
page.goto(core_app.url)
acc = controller.Accordion(page, "acc")
acc.expect_open(["Section A"])
acc.set(["Section B"]) # the click you just did by hand
acc.expect_open(["Section B"]) # the observable effect you just confirmed
Smoke coverage (app starts with no server/JS/output errors) is already automatic for every
discovered app.py/app-*.py via components/test_examples_smoke.py, so your
test_<name>.py only needs the interaction assertions. Run it with
uv run pytest components/<section>/<name>/test_<name>.py (or make test-components-examples).
Core and Express APIs can differ — verify the signature you actually call
Some server-side UI functions have a different signature in Express than in Core.
The classic trap is insert_accordion_panel:
- Core:
ui.insert_accordion_panel(id, panel, ...)— pass a pre-builtui.accordion_panel(...). - Express:
ui.insert_accordion_panel(id, panel_title, *panel_contents, panel_value=..., ...)— pass the title and body directly; there is no panel object, and in Expressui.accordion_panelis a context manager (one positional arg), so the Core form raisesTypeErrorat click time.
Don't assume a call that works in the Core demo works verbatim in the Express demo.
Check the real signature for the mode you're in (inspect.signature(shiny.express.ui.<fn>)
vs shiny.ui.<fn>) and copy py-shiny's own Express example from
py-shiny/shiny/api-examples/<fn>/app-express.py when one exists.
After the PR deploys: verify, then hand the author review links
Every PR gets a Netlify preview at https://pr-<N>--pyshiny.netlify.app (deployed from
_build/; find the exact URL via the "View deployment" button on the PR or
gh pr checks <N> / gh pr view <N>). Rendered visuals — screenshot cards, live WASM
apps, sidebar placement — are things you (Claude) can only partially judge, so the
author must do the final visual review. Do NOT close this out yourself.
Verify first. Wait for the deploy to finish, then load each new/changed component page in the preview and confirm the gallery card image rendered, the Preview app runs, the Core/Express tabs and their "Open in editor" links work, variations/kitchen sink render, and the new sidebar entry appears in the right section in the right order. Actually click every button / toggle every control in each live Preview app and confirm the promised effect happens with no error (see "Exercise every interactive control" above). Report anything broken instead of handing over links to a broken page.
Then give the author direct links — one per new/changed component, plus the gallery and the affected sidebar section, so they can eyeball the real output:
Preview: https://pr-<N>--pyshiny.netlify.app New/changed component pages: - Accordion: https://pr-<N>--pyshiny.netlify.app/components/layout/accordion/ Sidebar & gallery to check: - Components gallery (new card): https://pr-<N>--pyshiny.netlify.app/components/ - Layout section sidebar (order/placement): open any page above and check the left sidebar shows the new entry in the right section, alphabetically placedPage URL = the directory path under
components/with a trailing slash (components/<section>/<name>/). Ask the author to confirm each page and the sidebar before merging.
Common mistakes
| Mistake | Fix |
|---|---|
Preview tab points at app-core.py or app-preview.py |
Add a dedicated app-detail-preview.py |
app-preview.py written in Express style |
Rewrite as Core with module-level app_ui (static renderer imports app_ui) |
Hand-writing / pasting shinylive: values |
Add the key with a placeholder, run make docs-update-shinylive-links |
Non-Preview example app missing its shinylive: key |
Add the key (script skips apps without it and only warns) |
Inline preview: HTML when the app is screenshot-able |
Use appPreview: { file, static: true } |
Multi-file app without resources: |
List companion files under resources: and split with ## file: markers |
Forgot _quarto.yml sidebar entry |
Add the index.qmd path alphabetically in the right section |
| Empty/half-made component dir left behind | Remove it; a dir under components/*/ without index.qmd used to crash the link script |
Missing sidebar: components in front matter |
Add it — required on every component page |
relevant-functions lists only the constructor, not the update_*/insert_*/remove_* mutators |
Add an entry per mutator (title/href/signature); test_ui_api_has_page.py fails when a ui export is documented nowhere |
| Shipping a demo whose buttons were never clicked (compiles ≠ works) | Run each interactive app and exercise every control in a browser; assert the visible effect, then save it as a test_<name>.py interaction test (see testing-example-apps) so CI keeps checking |
Copying a Core ui.insert_*/ui.update_* call verbatim into the Express demo |
Check the Express signature — it can differ (e.g. insert_accordion_panel); copy py-shiny's api-examples/<fn>/app-express.py |