21 KiB
NiceGUI Page Layout And Styling
Use this reference to structure NiceGUI pages, choose component boundaries, apply responsive layout, and introduce custom CSS without fighting Quasar's internal geometry.
Ownership And Dependency Boundaries
Keep dependencies flowing in one direction:
- pages import components and services
- components contain presentation logic only
- services contain business logic and do not import UI
- bootstrap code mounts static assets and loads shared CSS once
Suggested module split:
src/my_app/
ui/
pages/
components/
static/
services/
api/
Page modules should compose a route from reusable presentation and service calls. They should not own domain rules, persistence, or long-running synchronous work.
Page Composition
Build the outer layout before styling individual controls:
- Define the page shell and width constraints.
- Establish responsive rows, columns, gaps, and wrapping.
- Add semantic sections and repeated components.
- Configure Quasar component appearance with props.
- Add custom CSS only for behavior that props and utilities cannot express safely.
with ui.column().classes("w-full max-w-6xl mx-auto gap-6 px-4"):
page_header(title="Inventory")
with ui.row().classes("w-full gap-4 flex-wrap lg:flex-nowrap items-start"):
filters_panel().classes("w-full lg:w-72 shrink-0")
item_grid().classes("w-full flex-1 min-w-0")
Use stable width, minimum-width, and flex constraints so labels, icons, validation messages, and loaded content do not shift the surrounding layout.
Component Extraction
Extract a presentation pattern to ui/components/ when it appears on two or more pages or when it owns a meaningful interaction boundary. Keep one-off route layout in the page module.
def card_section(title: str, content: str) -> ui.card:
with ui.card().classes("w-full max-w-md") as card:
ui.label(title).classes("text-lg font-bold")
ui.label(content).classes("text-gray-600")
return card
Reusable components should accept data and event callbacks rather than import page state or business services implicitly.
Styling Decision Order
NiceGUI wraps Quasar components. Choose the styling mechanism according to what it owns:
- Use Quasar props for component appearance, density, labels, and popup behavior.
- Use NiceGUI
.classes()and Tailwind utilities for width, spacing, alignment, and responsive layout. - Use reusable component functions for repeated visual patterns.
- Use
.style()for genuinely dynamic inline values. - Use minimal shared CSS only when props and utilities are insufficient.
Common Quasar props include:
outlineddensestack-labelpopup-content-classinput-classinput-style
Avoid overriding internal selectors such as:
.q-field__label.q-field__native.q-field__control.q-field__input
Quasar coordinates field height, padding, labels, values, icons, and floating-label transforms. Changing only one internal part tends to cause clipping or overlap.
Responsive Layout
Treat a breakpoint as the width where the content needs a different composition, not as a device label. Professional responsive systems use as few breakpoints as the content requires and let layout interpolate between them.
Use this decision order:
- Prefer intrinsic Grid or Flex behavior when the browser can adapt continuously without a breakpoint.
- Use Tailwind viewport variants for ordinary page-shell changes.
- Use CSS container queries when a reusable component should respond to its allocated width rather than the browser width.
- Use Quasar visibility classes when coordinating with Quasar drawers, headers, tables, and other layout behavior.
- Use Quasar's reactive Screen plugin only when JavaScript behavior or component props must change.
- Send viewport state to Python only when the server truly needs it; resizing should not normally create a client-server event stream.
Start With Intrinsic Layout
The cleanest breakpoint is often no breakpoint. Use wrapping, minimum sizes, and fluid tracks before adding width conditions:
with ui.element('section').classes(
'grid w-full gap-4 '
'[grid-template-columns:repeat(auto-fit,minmax(min(18rem,100%),1fr))]'
):
for item in items:
item_card(item)
Other high-value levers are:
flex-wrapfor toolbars and groups whose children have natural widthsmin-w-0for flex or grid children that must be allowed to shrinkminmax()andauto-fitfor fluid card gridsmax-w-*plusmx-autofor readable page shellsclamp()for bounded fluid spacing or type, not unbounded viewport scalingoverflow-x-autofor genuinely tabular data that cannot collapse without losing meaning
Add a breakpoint only when the intrinsic layout produces a specific failure such as an unreadable line length, clipped control, awkward empty space, or an action wrapping away from its context.
Know Which Breakpoint Scale You Are Using
NiceGUI exposes both Tailwind utilities and Quasar components. Their default breakpoint names do not mean the same widths.
| Name | Tailwind minimum width | Quasar half-open range |
|---|---|---|
xs |
Not defined by default | 0 to <600px |
sm |
640px | 600 to <1024px |
md |
768px | 1024 to <1440px |
lg |
1024px | 1440 to <1920px |
xl |
1280px | 1920px and wider |
2xl |
1536px | Not defined |
Tailwind's default values are defined in rem; the pixel equivalents above assume the usual 16px initial font size. Tailwind variants are mobile-first minimum-width rules. Unprefixed classes apply everywhere; sm:* starts at 40rem and continues upward. Quasar defines exclusive named bands and lt.* or gt.* tests around thresholds at 600, 1024, 1440, and 1920px.
This creates real disagreement zones. At 620px, for example, Quasar reports sm, while Tailwind's sm:* rules have not started. At 1100px, Quasar reports md, while Tailwind's lg:* rules are active. Do not compare breakpoint names across the two systems as if they were shared tokens.
Choose one owner for each responsive decision:
- Use Tailwind consistently for a page's spacing, grid, width, and flex changes.
- Use Quasar's scale for Quasar component behavior and visibility tied to its layout system.
- If one element must coordinate across both systems, use explicit width values in shared CSS or configure and verify a unified project scale.
- Avoid custom breakpoints until repeated content failures justify a new token. Keep custom Tailwind breakpoints in the same unit, normally
rem, so generated rules sort predictably.
Use Tailwind Mobile-First Variants
Use Tailwind breakpoint classes for ordinary page adaptation:
with ui.row().classes("w-full gap-4 flex-wrap sm:flex-nowrap"):
ui.card().classes("w-full sm:flex-1 sm:min-w-64")
ui.card().classes("w-full sm:flex-1 sm:min-w-64")
- Start with a usable mobile layout, then add larger breakpoint behavior.
- Allow dense toolbars to wrap or collapse intentionally.
- Use
min-w-0on flexible content that must shrink inside a row. - Keep controls and primary actions visible without horizontal scrolling.
- Test the longest realistic labels, values, errors, and menu options.
Think of sm: as "from 640px upward," not "on phones." When a style should apply only within one band, combine minimum and maximum variants such as md:max-lg:*. Arbitrary variants such as max-[700px]:* are appropriate for a proven one-off content threshold, but repeated arbitrary values are a signal to define a named project token or shared CSS rule.
Use Container Queries For Reusable Components
Viewport breakpoints answer "how wide is the browser?" Container queries answer "how much room did this component receive?" The latter is usually correct for a card, filter panel, inspector, or reusable toolbar that can appear in a full-width page, drawer, split pane, or dialog.
Use native CSS when the project's Tailwind or UnoCSS runtime does not guarantee container-query variants:
with ui.element('section').classes('result-panel'):
with ui.element('article').classes('result-card'):
result_summary()
result_actions()
.result-panel {
container: results / inline-size;
}
.result-card {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
}
@container results (width >= 36rem) {
.result-card {
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
}
}
Use container-type: inline-size or the container: <name> / inline-size shorthand for normal horizontal adaptation. Name containers when nesting makes the nearest query container ambiguous. Keep a usable one-column or wrapped layout as the fallback.
If the enabled utility engine supports Tailwind container variants, the equivalent shape is concise:
with ui.element('section').classes('@container'):
with ui.element('article').classes(
'flex flex-col gap-4 @min-[36rem]:flex-row'
):
result_summary()
result_actions()
NiceGUI can run Tailwind or an UnoCSS preset, and compatibility is not identical. Verify container-query and arbitrary-variant support against the selected engine before standardizing on utility syntax; native CSS remains the portable baseline.
Use Quasar Visibility And Screen State Deliberately
For pure show/hide behavior tied to Quasar's scale, prefer Quasar responsive CSS classes over JavaScript. Typical classes include xs, sm, md, lg, xl, lt-sm, lt-md, lt-lg, lt-xl, gt-xs, gt-sm, gt-md, and gt-lg.
ui.button('Menu', icon='menu').props('flat').classes('lt-md')
ui.row().classes('gt-sm').props('aria-label="Primary navigation"')
Do not render two interactive versions of a control unless both copies have correct labels, focus behavior, state synchronization, and accessibility semantics. Prefer changing layout around one control when possible.
Use the Quasar Screen plugin when a component prop must react rather than merely its CSS. NiceGUI passes props beginning with : as client-side JavaScript expressions:
ui.table(columns=columns, rows=rows).props(':dense="$q.screen.lt.md"')
Quasar exposes $q.screen.width, height, name, named-band booleans, and lt/gt flags. Its own guidance prefers responsive CSS classes when possible for performance. Changing Screen plugin sizes at runtime does not change CSS breakpoints, so avoid calling setSizes() unless the project also updates and verifies the corresponding stylesheet tokens.
Body classes such as screen--sm are opt-in Quasar configuration and can delay first meaningful paint. They are rarely needed in NiceGUI when media queries, responsive utility classes, or direct $q.screen expressions already solve the problem.
Width Is Not The Only Lever
Screen width is a poor proxy for interaction capability. A wide touchscreen can still have a coarse pointer, while a narrow desktop window can have hover and a mouse. Use media features for the capability being adapted:
.icon-action {
min-width: 2.75rem;
min-height: 2.75rem;
}
@media (hover: hover) and (pointer: fine) {
.icon-action:hover {
background: var(--action-hover-background);
}
}
@media (pointer: coarse) {
.icon-action {
min-width: 3rem;
min-height: 3rem;
}
}
@media (prefers-reduced-motion: reduce) {
.app-shell *,
.app-shell *::before,
.app-shell *::after {
scroll-behavior: auto;
transition-duration: 0.01ms;
animation-duration: 0.01ms;
animation-iteration-count: 1;
}
}
Use hover for hover affordances, pointer for target sizing, prefers-reduced-motion for non-essential movement, orientation only when orientation itself changes the usable composition, and height queries for short viewports such as landscape phones or split-screen windows.
What Experienced Teams Standardize
Mature implementations usually standardize the decision process more than the number of device presets:
- A small, documented viewport scale for application shells.
- Component-owned container thresholds for portable reusable UI.
- Design tokens for page max widths, gutters, minimum control sizes, and readable line lengths.
- Mobile-first base styles with progressive enhancement at wider sizes.
- CSS-driven adaptation; JavaScript only when behavior or data requirements change.
- Content-driven breakpoint selection based on observed failure points.
- A viewport test matrix that includes exact boundaries and one pixel on each side.
Do not maintain Python booleans such as is_mobile from user-agent strings. Do not hardcode separate phone, tablet, and desktop component trees when CSS can reflow one semantic tree. Device categories age quickly; content constraints and input capabilities are durable.
Static Assets And Shared CSS
- Mount static assets from the composition layer.
- Load shared CSS once rather than injecting it from individual pages.
- Keep custom CSS tokenized with variables and scoped to application classes.
- Avoid broad rules against Quasar internals.
- Verify mount paths, reverse-proxy rewrites, and cache behavior.
from pathlib import Path
from fastapi.staticfiles import StaticFiles
STATIC_DIR = Path(__file__).parent / "ui" / "static"
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
ui.add_css((STATIC_DIR / "css" / "base.css").read_text(encoding="utf-8"))
Responsive Dialog Pattern
Use whole-card scaling when a form dialog must become uniformly larger on mobile while preserving Quasar's internal proportions. Keep detached select menus unscaled and make the card itself scrollable.
Use Normal Field Density
Normal Quasar fields are approximately 56px high, while dense fields are approximately 40px high. Remove dense when larger controls are needed.
ui.input("Name").props("outlined")
ui.number("Quantity").props("outlined")
ui.select(...).props(
"outlined popup-content-class=app-item-detail-menu"
)
ui.textarea("Description").props("outlined autogrow")
Add a scoped class to the dialog card:
ui.card().classes("app-detail-card app-item-detail-card")
Scale The Complete Card
:root {
--item-dialog-scale: 1;
--item-dialog-max-height: calc(100dvh - 3rem);
}
.app-item-detail-card {
width: min(50rem, 50vw);
max-height: var(--item-dialog-max-height);
overflow-y: auto;
overscroll-behavior: contain;
zoom: var(--item-dialog-scale);
}
/* Restore Quasar's baseline if a global rule overrides it. */
.app-item-detail-card .q-field,
.app-item-detail-menu {
font-size: 14px;
}
@media (max-width: 599px) {
:root {
--item-dialog-scale: 1.2;
/* 75dvh becomes 90dvh after 1.2x zoom. */
--item-dialog-max-height: 75dvh;
}
.app-item-detail-card {
width: 80vw;
}
.app-item-detail-menu {
font-size: 16.8px;
}
}
The main mobile tuning knob is:
--item-dialog-scale: 1.2;
Keep Detached Popups Unscaled
Do not apply zoom or transform: scale() to a QSelect popup menu. Quasar renders menus outside the dialog and positions them from the unscaled anchor geometry. Scaling the menu container afterward separates it from its field.
Avoid:
.app-item-detail-card,
.app-item-detail-menu {
zoom: 1.2;
}
Use:
.app-item-detail-card {
zoom: 1.2;
}
.app-item-detail-menu {
font-size: 16.8px;
}
Use popup-content-class=app-item-detail-menu to target the detached menu and enlarge its text without changing its coordinate system.
Account For Zoom When Scrolling
The card's pre-zoom maximum height must account for the scale:
[ \begin{aligned} h_{\mathrm{pre}} &= \frac{h_{\mathrm{visible}}}{s} \ \text{where } s &= \text{the zoom scale} \end{aligned} ]
For a desired visual height of 90dvh at (1.2\times):
[ \frac{90,\mathrm{dvh}}{1.2} = 75,\mathrm{dvh} ]
Therefore:
--item-dialog-max-height: 75dvh;
Apply scrolling to the card itself:
.app-item-detail-card {
max-height: var(--item-dialog-max-height);
overflow-y: auto;
overscroll-behavior: contain;
}
This keeps the dimmed page stationary while the form scrolls.
Match The Quasar Breakpoint
Quasar's extra-small breakpoint ends at 599.98px. A mobile-only rule can use:
@media (max-width: 599px) {
/* Mobile rules. */
}
Confirm custom breakpoint values against the target application's Quasar configuration.
Validation Checklist
Test the complete page at representative mobile and desktop viewports. For a dialog, include a mobile viewport such as 390 \times 844.
For every breakpoint actually used by the page, test immediately below, exactly at, and immediately above it. For a 640px threshold, that means widths of 639, 640, and 641px. This catches inclusive/exclusive mistakes and exposes Tailwind/Quasar disagreement. Do not multiply this into every possible height; pair boundary widths with the heights that stress the feature.
Use a compact matrix that covers:
- The narrowest supported viewport, not only a current flagship phone.
- A typical portrait phone and a short landscape phone.
- Every active content breakpoint at
b - 1,b, andb + 1. - A common laptop width with browser sidebars or devtools reducing available space.
- A wide desktop to verify maximum widths and avoid stretched content.
- A narrow component inside a wide viewport when container queries are used.
- Keyboard navigation and 200% browser zoom.
- Coarse-pointer, hover-capable, and reduced-motion emulation where those queries exist.
Prefer assertions about invariants over screenshot-only approval: no horizontal document overflow, no overlap, minimum target sizes remain usable, the intended number of grid columns is present, drawers and actions have the expected visibility, and focus order remains logical. Add screenshots for visual regression, but keep geometry and accessibility checks for failures a pixel diff cannot explain.
- Page sections do not overlap or introduce unintended horizontal scrolling.
- Responsive rows wrap or resize as designed.
- Dialog remains inside the viewport.
- Dialog has
scrollHeight > clientHeightwhen its content is taller than its maximum height. - Scrolling reaches the final form field.
- Select menus open directly against their fields.
- Menus have no horizontal overflow.
- Values and floating labels are not clipped.
- Select arrows and other icons scale with the card.
- Detached popup menus report
zoom: 1.
As a precision check, compare the menu edge with the field edge using browser geometry. One corrected implementation measured within approximately 0.5px horizontally and 0.14px vertically; treat those values as an example observation, not a framework guarantee.
Playwright Caveat
Playwright locator clicks can calculate incorrect coordinates for elements inside CSS zoom. A failed locator click does not necessarily mean browser interaction is broken.
For verification, either:
- click using manually adjusted visual coordinates
- trigger the element through DOM evaluation
- test the interaction manually in a real browser
Do not alter otherwise correct component styling solely to accommodate this automation limitation.
Sources
!!! info "Primary sources"
- NiceGUI element styling and props
- NiceGUI binding properties
- Quasar components
- Quasar field
- Quasar select
- Quasar Screen plugin documentation source
- Quasar Screen plugin implementation
- Tailwind responsive design
- MDN media queries
- MDN container queries
- web.dev content-driven breakpoints
- MDN hover capability
- MDN pointer accuracy
- MDN reduced motion
- MDN zoom
- Playwright input actions