ConventionsUI

UI

The four regions every tool screen is built from, what belongs in each, and the overlay vocabulary that differs between web and mobile.

A tool screen — a screener, a trends board, a value-bet list — is the same four regions on both platforms. What differs is how the two filter regions are presented, because a phone has no room for a persistent panel.

The four regions

RegionHoldsNever holds
Nav sidebarCategories and the tools inside themFilters
Top controlsPage-wide view, search, filter, fixture and sort controlsResult cards, tables or other feature content
Filter panelEvery other filter for this toolAnything required to understand the board
ContentThe board, inlineChrome, or a card around the board

The dividing question between the top controls and the panel is "would a reader need to see this without opening anything?" Search and the primary dimension belong at the top; a bookmaker allow-list does not.

Every panel control lives in a titled group

Nothing in the filter panel renders on its own. A control needs a FilterGroup, and the group needs a FilterGroupLabel — which is also where its icon comes from, resolved from the title by filterSymbol (see icons and imagery).

<FullToolFilters>
  <FilterGroup>
    <FilterGroupLabel>Sources</FilterGroupLabel>   {/* title + resolved icon */}
    <FilterGroupContent>
      <FilterToggleGroup id="bookmakers" options={bookmakers}  />
    </FilterGroupContent>
  </FilterGroup>
</FullToolFilters>

A bare control in the panel has no name, so the reader has to infer what a list of logos filters. It also cannot collapse, so it costs its full height in a column that is a stack of eleven other groups.

Two failures this rule catches, both of which were live:

  • A panel with no groups at all. Player cards had its date control on the bar and pinned in the footer, and nothing in the panel body — so opening "All filters" showed an empty column.
  • A component that returns a bare control. A sub-component is free to return one, as long as every call site wraps it. If no call site does, the file is dead — that is how an orphaned bookmaker-filter.tsx survived, exporting a FilterToggleGroup nothing rendered.

The bar and the footer are the exceptions, and only because they are one row tall: a pill carries its own label, and a footer control sits beside Save and Reset. Everything in the scrolling panel is grouped.

Never put a dropdown inside a collapsible

A filter group is already a disclosure. You opened Statistic to find out what the statistic is — and a closed Select inside it answers with a trigger you have to open too. The group then costs two clicks to read, and shows one word when it is open, which is the same as being shut.

So the control depends on where it renders, not on how many options it has:

WhereSingle choice renders asWhy
Filter panel groupFilterChoice — the options, shownThe group is the disclosure; nothing else should hide
Important-filters barFilterSelect — a triggerOne line of height, and the pill already summarises the value
Panel footerFilterSelect — a triggerSame: one row, pinned

FilterChoice shows a filter field of its own once the list reaches eight options, because a visible list of forty is its own kind of hiding. Below that it is rows, each with its icon, and a tick on the selected one.

The same reasoning rules out the other nested disclosures: no Popover, no Accordion, and no second FilterGroup inside a group. A group opens once and everything inside it is then legible.

The schema route does this for you

AdvancedFilters picks the rendering from the slot it is filling — a kind: "select" filter is a trigger on the bar and a shown list in the panel, from one declaration. Only hand-rolled panels can get this wrong.

Stack page-wide controls above content

A tool may need more than one control row. Keep each control board as a separate FilterBarRow, in this order:

  1. The Important filters row: search, primary dimension and panel actions.
  2. A league, team or category rail.
  3. A fixture rail or other dependent control.
  4. Subpage tabs, last — nearest the board they switch.

Rows stay one line tall and scroll on their own horizontal axis. Do not wrap a long chip rail and do not place search and chips in one overflowing row. The shell draws a divider before every additional row, including rows that arrive through a portal.

Subpage tabs go last, small and left-aligned

They were first, centred and at text-base, which made them the loudest thing on the page and put two rows of chrome between a tab and the board it changes. They are the least-used control up there — a reader picks a board once and then works in it — so they read as the quietest:

  • Last, immediately above the content.
  • Left-aligned, packed at their label width rather than stretched across the row or centred.
  • size="sm", which puts variant="line" tabs at the same text-xs as the subsection labels and the chip rail rather than a step above them.

ScopeSwitch does all three. size="lg" stays for tabs rendered in the page BODY, where the tabs are the heading — prop-screener's all/watchlist row is the example.

The bar does not stick

The header does; the filter bar does not. Four stacked rows pinned under a 64px header took a third of a short viewport before the board began, and the row a reader reaches for mid-scroll is the pills — which are in the filter panel too, so nothing is lost by letting the bar scroll away.

Horizontal overflow always has an edge cue

Every horizontal rail shows an edge fade while content remains outside its viewport. Use HorizontalScroll from @statshub/ui-web; it observes the real scroll range, shows the right fade at the starting edge, adds the left fade after scrolling, and removes each fade when that edge is reached.

import { HorizontalScroll } from "@statshub/ui-web/components/horizontal-scroll";

<HorizontalScroll className="flex gap-2 scrollbar-hide">
  {leagues.map((league) => (
    <LeagueChip key={league.id} league={league} />
  ))}
</HorizontalScroll>;

Do not add a permanent gradient over a rail. A fade with no overflow, or one that remains after the last item, falsely advertises hidden content. The raw Table primitive and DataTable already use HorizontalScroll; do not wrap either in a second horizontal scroller. scroll-fade-x remains the migration class for an existing scroll container that cannot yet use the component.

Page-wide controls never render inside feature content. This includes view switchers, search fields, filter chips, league and fixture rails, and result sorting. Put them in ImportantOnlyToolFilters or ToolFilterRows; the content tree should start with the title, status and results.

This rule does not move controls whose scope is one content component. Table pagination and column visibility, a card's actions, and a dialog's form stay beside the content they operate on. Moving those to the page top would hide their scope rather than clarify it.

Content is inline, never in a card

The content region is already a region. It sits inside AppWorkspace between the control stack and the edge of the screen, and a reader can see where the board begins without a border being drawn around it. Wrapping it draws a second boundary a few pixels inside the first and buys nothing:

  • The board reads as inset twice, once by the workspace and once by the card.
  • An empty state inside a card is a box, inside a box, around a sentence.
  • The card's own padding fights the workspace inset every other tool aligns to, so a table in a card stops lining up with a table that is not in one.

Render the board, its empty state and its pagination row straight into the content region.

// Wrong — a border around the region that already has one.
<div className="mx-auto rounded-xl border-2 p-4">
  <ResultsTable />
  <Pagination />
</div>

// Right.
<ResultsTable />
<Pagination />

An empty state centres, which means every wrapper above it is a flex column

Empty and ErrorState are already flex-1 items-center justify-center. That is the whole of their centring, and it does nothing on its own: flex-1 needs a flex parent, and a flex parent needs height to hand out. Break the chain anywhere between AppWorkspace and the state and it silently falls back to its own min-h and sits at the top of a tall board — no error, no warning, just a sentence pinned under the filter bar with an empty screen beneath it.

The chain is four links, and all four have to hold:

<AppWorkspace>            {/* flex min-h-[calc(100svh-4rem)] flex-1 flex-col */}
  <div className="flex min-h-0 flex-1 flex-col">   {/* the content region */}
    <DataTable />         {/* flex flex-col, and flex-1 WHILE showing a state */}
      <Empty />           {/* flex-1 items-center justify-center */}

Two things worth saying out loud about the third line:

  • flex-1 only while it is showing a state. A table that stretched would push its last row away from the rows above it, leaving a gap mid-board.
  • min-h-0 on the content region, not just flex-1. A flex child's default min-height: auto refuses to shrink below its content, so a long table stops the region from scrolling.

Do not fix a state that will not centre by giving it a taller min-h. That moves the sentence down without centring it, and it is wrong again at the next viewport height. Find the link in the chain that is not a flex column.

This is about the container, not the rows

A card is still the right shape for a repeated unit inside the content — one bet, one fixture, one player — where its border separates a row from the row beneath it. What must never be wrapped is the content region itself.

Use DataTable for primary row-based results

A route-level board whose main content is a set of comparable rows uses the shared DataTable from apps/statshub-web/src/components/reusable/data-table.tsx. Referees is the reference implementation. Team Screener, Prop Screener, Super Sub Heroes, Data Visualizer event results and Odds Converter follow the same contract.

Every primary data table has:

  • column definitions created with createDataTableColumnHelper;
  • sorting, pagination, selection and column visibility controlled through nuqs, so a reload or copied URL preserves the view;
  • loading skeletons inside the table body, with error and empty states rendered alone in the content region instead of inside table chrome;
  • a stable getRowId derived from domain identifiers, never the row index;
  • column visibility in ToolBarActions and navigation in the table footer;
  • one horizontally scrollable table on compact screens, not a second card representation with a different schema.

Use the raw Table primitive only for compact nested matrices, such as a fixture comparison inside a detail panel. Cards remain correct for repeated units, charts remain charts, and calculators keep their purpose-built layout. The rule applies when the route's primary result is tabular; it does not turn every collection into a table.

Keeping this boundary explicit prevents each feature from inventing its own loading placement, mobile schema, sort control and pagination language.

No caption counting the results

"4 trends found" tells a reader what the rows under it already tell them, and it costs a line at the top of every tool. Nothing states the size of a result set in prose — not above the board, not under it:

// Wrong.
<span>{pagination.totalCount} trends found</span>
<span>Showing {from} to {to} of {total} entries</span>

// Right — the rows are the count.

The reader still gets the number where it does work: the pagination control keeps Page {currentPage} of {totalPages}, because that one is navigation rather than a summary — it says what pressing Next will do.

An empty state is not a count and stays. "No value bets found" is the only thing on screen when the board is empty; it is content, not a caption.

A number is a slider and a box, never one of them

A slider alone cannot express 2.5 when the step is 1, and it cannot be typed into. A box alone gives no sense of the range — is 40 a lot? The three numeric controls in filter-controls.tsx used to pick one each, so the same quantity looked like a different kind of thing depending on which panel you opened.

They now all render both, and share one NumberField:

ControlShape
FilterRangeone two-handle slider, a box at each end
FilterNumberRangeone slider and one box per side, stacked
FilterThreshold / box / +, with the slider under it

Two rules the shared NumberField encodes, both learned from getting them wrong:

  • Typed digits commit on blur and on Enter, not on keystroke. Mid-typing, 2. and "" are legitimate things to have in the box. Pushing either upstream snaps the slider to zero and bounces the caret.
  • Dragging is local until release. The slider holds its own value while the handle is down and calls onValueChange on commit, so a drag across a server-backed filter fires one request rather than forty.

FilterNumberRange keeps its bare pair of inputs when min and max are not both given: an unbounded server filter has no track to draw.

Two numbers are not always a range

FilterNumberRange is two independent values, which is why it draws two single-handle sliders rather than one two-handle range. The build-ups calculator puts Player 1 on from and Player 2 on to, and Player 1 is routinely the larger — a range slider would silently reorder them.

Selects say whether they take one answer or several

FilterChoice takes multiple. Single is the default, and the rows are radios; with multiple they are checkboxes and value is an array. It is one prop rather than a second component because the search field, the keep-the-selection-visible rule and the row chrome are the parts worth sharing.

Reach for FilterToggleGroup instead when the group wants a select-all.

"Is this still the default" for a multi-valued control compares members, not order — picking Goals then Shots is the same filter as Shots then Goals, and a join() comparison would light the active count up for a click sequence.

Domain repetition lives in components/helpers/

Three directories, three jobs:

DirectoryHolds
components/reusable/generic controls — FilterRange knows about numbers, not odds
components/helpers/this product's recurring shapes — OddsFilter knows odds run 1.01 to 10
components/filters/one tool's panel, assembled from both

A helper is config-first and deliberately narrow. OddsFilter takes a value and a handler; the scale, the step, the two-decimal format and the group label are not parameters, because a second opinion about what an odds range is is exactly the thing being prevented.

Every sidebar group also crosses the ToolFilter helper boundary. Feature modules use ToolFilter, ToolFilterLabel, ToolFilterContent and ToolFilterSubsection; they do not import the design-system group primitives directly. A regression test discovers filter modules and FullToolFilters consumers wherever they live, so moving a panel outside components/filters/ does not bypass the rule.

tool is the id prefix — <OddsFilter tool="prop-screener" /> registers prop-screener-odds. Ids reach the saved-view library and the panel's active count, so they stay stable and unique per tool.

Where a helper covers something AdvancedFilters also renders, it ships in both forms — <OddsFilter> for panels written as JSX, oddsFilterDef() for panels declared as data — reading the same constants, so the two cannot disagree.

The four so far, each found by counting rather than guessing — and every one of the copy sets had already drifted:

HelperCoversFound in
tool-filterssidebar group boundary, odds scale, hit-rate window, side, bookmakersevery sidebar filter module
tool-filter-barthe bar's pills, and the league pill whole20 pills, 7 of them leagues
tool-paginationthe pager15 files, 6 with the page strip
tool-table-columnscrest and numeric cells, header + metaevery DataTable

The drift is the point. One screener's page strip stepped back one page and the other's stepped back two. TeamCrest existed twice, byte for byte apart from a single ? in its prop type. Every league pill re-derived the same rule — a pill shows a crest only when exactly one league is picked, because two crests is a worse label than a count — and derived its summary string separately from the value it described, which is how a pill ends up saying "All leagues" while the panel shows three selected.

The bar and the panel are different helpers on purpose. tool-filter-bar is the pills; tool-filters and AdvancedFilters are the panel underneath.

One-off meaning still uses the helper boundary

A control unique to one board stays composed at its call site, inside a ToolFilter. Do not grow a named domain helper by adding one prop per caller, and do not bypass the boundary with raw filter-group primitives.

A feature renders. A composed owns.

components/features/ (web) and blocks/ (native) hold UI only. No useSWR, no fetch, no useQueryState, no derivation of one value from another. A feature takes props and returns markup. If you can't test it by passing it an object, it is in the wrong folder.

Everything else lives in composed/, one directory per feature, named the same:

composed/team-screener/team-screener.tsx   ← state, queries, derivations
components/features/team-screener.tsx      ← props in, markup out
app/…/team-screener/page.tsx               ← imports the composed

The route imports the composed, never the feature. That is what keeps the rule enforceable: a feature with a useSWR in it has nowhere to get its data from, because nothing above it is passing any.

Native already works this way — composed/referees/referees.tsx runs the query and hands rows to blocks/referees/referees-block.tsx — so this is the web app catching up to a shape the product already has, not a new invention.

Why bother. These files reached 5,000 lines by growing a fetch next to the markup that needed it, forty times. Splitting them is not tidiness:

  • A feature you can hand props to can be rendered in Storybook, in a test, or in a second context, without a network.
  • The logic becomes readable on its own. team-screener's data flow was 300 lines interleaved with JSX; alone in a composed it is 300 lines you can actually follow.
  • Two tools that fetch the same thing become visibly the same fetch.

Shared state types go in the composed, not the feature — TeamFiltersState and its DEFAULT_FILTERS describe what the tool is steered by, and a filter module importing them from a UI file is what made the old boundary meaningless.

The split is by dependency, not by line count

A useMemo that maps rows to display strings is UI work and stays. A useMemo that decides which endpoint to call is not. The question is never "is this logic" — it is "does this reach outside the component for anything".

A page that is not a tool is not a feature either

The rule above is about tools. Some pages are not: the odds converter and the stats explainers are there to be found by search, and the Lineup Hunter and Odds API pages are there to sell something. They have no board, no filters and no composed to own them, so features/ — where every neighbour is one screen of the product — is the wrong shelf.

They get their own two, named after what they are for:

components/seo/         odds-converter, player-stats, team-stats
components/marketing/   lineup-bot, odds-api
app/(seo)/…             both groups render PublicRootLayout, not the app shell
app/(marketing)/…

The route groups already split this way, and both render PublicRootLayout: no sidebar, no filter panel. The folders now agree with them, which is what makes "is this a tool?" answerable by looking rather than by reading the file.

A non-tool stays out of the tool catalogue. statshub-config gives an entry a category only when it belongs in a group in the sidebar. Lineup Hunter keeps an entry without one — its title and summary are read by metadata and by the bird's-eye view — and that absent category is the whole reason it never shows up in the sidebar, search or a breadcrumb.

On web

apps/statshub-web/src/components/layout/app-sidebar.tsx builds all of it. AppShell is a SidebarProvider wrapping AppSidebar and AppWorkspace.

RegionRendered as
Nav sidebarAppSidebar, --sidebar-width: 13.5rem, defaultOpen={false}
Filter panel, desktopA sticky <aside>, w-72, with Saved views fixed above its scrolling filters
Filter panel, mobileA left-side <Sheet> — see overlays
Top controlssticky top-16; one primary row followed by zero or more independent rows. All filters opens the mobile panel
ContentSidebarInsetAppHeader → top controls → one shared inset → children

All of the filter chrome renders only when findTool(pathname) matches. A page that is not a tool gets the nav sidebar and the content, and nothing else.

Content insets belong to the workspace

AppWorkspace gives every tool the same content edge: px-4 on compact screens, sm:px-6 from the small breakpoint upward, pt-6, and pb-8. Feature views do not repeat those outer padding utilities. Their root may set a width constraint such as mx-auto max-w-6xl, but it must inherit its inline and top inset from the workspace.

That inline edge is shared, not the content region's alone. The header holding the breadcrumbs and every FilterBarRow use the same px-4 sm:px-6, so the breadcrumb, a row of filter pills, a league rail and the board beneath them all begin on one vertical line. A bar at a tighter px-4 while the board sat at sm:px-6 read as a misalignment rather than as a deliberate inset, because that is what it was.

This keeps a wide table, an empty state and a narrow calculator aligned even when the control stack above them has a different number of rows. Padding inside a card or table toolbar still belongs to that component; it is not a page inset. If content genuinely needs to bleed through the shared gutter, make that exception explicit at the content boundary with -mx-4 sm:-mx-6.

Every tool starts with a visible PageIntro. It composes the shared Alert with a centered h1 and description; the breadcrumb does not replace it. Keep the alert at the inherited workspace edge, before status and result content.

Filters arrive by portal, not by the slot they look like

app/app has @filters and @importantFilters parallel-route slots, and both render null. They exist to reserve the region; the filters themselves are portalled in from the feature view:

a feature view
<ImportantOnlyToolFilters>
  <PrimaryDimension />
</ImportantOnlyToolFilters>

<ToolFilterRows>
  <FilterBarRow>
    <SearchInput />
  </FilterBarRow>
  <FilterBarRow>
    <LeagueRail />
  </FilterBarRow>
</ToolFilterRows>

<FullToolFilters>
  <LeagueSelect />
  <BookmakerToggles />
</FullToolFilters>

FullToolFilters portals into #app-full-tool-filters in the panel, ImportantOnlyToolFilters into the primary top row, and ToolFilterRows into the row stack below it.

Two positions on the primary row are fixed for every board, so the controls a reader reaches for first do not move between tools:

PortalLandsHolds
ToolSearchImmediately after the "Important filters" labelThe tool's text search
ToolBarActionsImmediately left of ClearColumn visibility and the like

A board does not put its search on a row of its own, in a toolbar above the table, or inside the content. ToolSearch is bounded rather than greedy — the pills beside it are why the row exists.

Declare each rendering explicitly

Use ImportantOnlyToolFilters when the compact control has a fuller equivalent inside FullToolFilters. Use ImportantToolFilters only when the exact same control is complete enough for both locations. ToolFilterRows is top-only.

This is why a view declares its filters where the filters logically live — beside the state they drive — rather than in a route file far from it.

The panel's open state is in the URL

const filterPanelParser = parseAsStringLiteral(["open", "closed"] as const)
  .withDefault("closed")
  .withOptions({ history: "replace", shallow: true }); 

history: "replace" matters: opening a panel is not a navigation, so it must not add a back-button step. shallow: true keeps it off the server. See where state lives.

New tool screens go in app/app, not (legacy)

21 of the 23 views using these portals are still under (legacy). The portal itself lives in app/app, and (features) is where the rebuilt screens go.

A divider runs wall to wall, and the gutter goes inside it

A rule is the edge of the section it heads. An edge that stops short of the content it spans does not read as a boundary — it reads as an underline on a label, and the eye takes the section to be narrower than it is.

So a row that draws a rule breaks out of its container's horizontal padding and puts the same padding back inside itself. The border reaches both walls; the first word, the first cell and every heading below it still line up.

<div className="full-bleed border-b border-border">…</div>

full-bleed is one utility in globals.css and it is the whole move — negative margin out, matching padding back in, and the explicit width a percentage needs so the negative margin stretches the box instead of sliding it sideways.

How far to bleed is the container's answer, never the row's. A container that pads its children declares --gutter with the value it pads by:

<div className="px-4 sm:px-6 [--gutter:1rem] sm:[--gutter:1.5rem]">

The board workspace already does, so anything inside a tool page can bleed without knowing where it is. A card that pads more tightly sets its own, and the row inside it stops at the card's edge instead of hanging over both sides. A container that declares nothing gets a row that stops where it always did — the fallback is zero, because a guess at someone else's padding is how a rule ends up 12px past the edge it was meant to meet.

A panel with its own border is already the edge

This is for a rule inside a padded region. A bordered, rounded container — a card, DataTable's scroll box — IS the boundary, and its dividers run to that border and stop. Bleeding one of those past its own outline is not full bleed, it is a broken card.

ContentTabsList is the reference implementation: the rule under a board's tabs is the top edge of everything below it, and it has run wall to wall since before the utility existed. The lineup card's header and footer rules, the filter bar and the app header all draw the same line for the same reason.

It is also the only in-page tab treatment. Its compact type and icon sizes are fixed rather than caller-selectable. Labels are plain text: no counts, status badges, promotional New markers or responsive type overrides. Put a result count beside the result set, not inside the control that selects it.

Segmented controls: filled sets, underlined navigates

Two variants, and the choice is not cosmetic.

VariantMeansUse for
default — a filled track"this is the setting"A filter with two or three values
line — a rule with the active tab underlined"this is where you are"Sibling routes: Player/Team, All props/Watchlist

Rendering route tabs filled made a page switcher look like a filter sitting among the filters, which is why ScopeSwitch — the route-backed one — is always variant="line", and always full width. A rule that stops under the last word reads as an underlined label; one that spans the row reads as the edge of a section.

The underline travels

A line list draws one rule for the whole row and slides it from the tab you left to the tab you picked. It comes with the variant — TabsList renders the indicator itself, and it holds still under prefers-reduced-motion.

Two things follow from there being exactly one of them:

  • A row of buttons that each draw their own underline is a bug. The home board's date rail was seven of those, so the rule blinked out under Today and back in under Wed rather than stepping across the week. If a set of things reads as tabs, build it as a tab list; the travel and the arrow-key navigation come with it.
  • Do not bold the active tab in a rail of uneven labels. "Wed 2 Sept" going semibold reflows every tab to its right while the rule is still moving. Mark it with colour instead, as the date rail does.

The template owns the scope row

Pass the options, not a rendered control:

<FeatureTemplate
  scope={[
    { label: "Player", href: "/prop-screener/all" },
    { label: "Team", href: "/prop-screener/team" },
  ]}
  content={<ResultsTable />}
/>

Every board used to wrap its own ScopeSwitch in the same SegmentedToolFilters + FilterBarRow pair, so five copies of the wrapper had to agree about where the row sits and which variant it uses. The template owns both now, and a board with scopes cannot put them anywhere else.

FeatureTemplate renders those options through ScopeSwitch, which in turn uses ContentTabsList. Template-owned scopes and local section switches therefore share the same compact row; neither exposes a large size or a badge slot.

On mobile

There is no nav sidebar and no persistent panel — a phone has room for the board or for the filters, not both. The regions map onto three pieces of @statshub/ui-native:

Web regionNative equivalent
Important filter barfilters/filter-bar.tsx — a scrolling row of chips
Filter panelfilters/filter-panel.tsx — grouped rows, inside a sheet
The sheet around itsheet/filter-sheet-screen.tsx — titled bar, body, pinned footer

FilterSheetScreen is a nested Expo Router stack, and which routes are in it is the board's business — the kit owns the chrome, not the navigation.

Four rules the chip row encodes, worth knowing before you build one:

  • A chip is a control, not a Badge. It is pressable, it has a selected state, and it usually opens something. Badges have none of those. The kit keeps them apart so nobody reaches for the label when they want the button.
  • Selected is a fill, not a tick. A tick has to be found and read; a filled chip is seen. Scanning a filter bar, the reader's real question is "what is currently on", and the answer has to survive a glance.
  • The count lives in the chipLeagues · 3, not a separate badge. A chip that says only "Leagues" makes the reader open it to find out.
  • onClear is part of the bar. A board narrowed to nothing with no visible way back is the commonest dead end in a filtered interface.

Advanced filters that fit one screenful use FilterPanel directly. The screener's tray predates it and keeps its own copy, coupled to a seven-view stack — do not extend the panel to cover that case.

On this page