Loading, empty and error
How every data region shows it's loading, empty or failed.
Every piece of a screen that fetches data shows one of four things: loading, empty, failed or the data. The page frame appears straight away; each part loads on its own inside its own frame, and nothing jumps when the data arrives.
The problem
A Bridge screen pulls from several places at once: intros, contacts, numbers. If the whole page waits for the slowest part, people stare at a blank screen. If a card hides when its data fails, people can't tell broken from empty, and a count that failed but shows 0 tells them something false.
What to do
- Frame first, then each part. The page and every card's title show straight away. Each card loads on its own, inside its own frame, through DataState.
- Hold the space. The skeleton is the same size and shape as what replaces it, so nothing jumps when the data arrives.
- Every state says something. Empty says what will be here and how to fill it. Failed says "Couldn't load …" and offers Try again. Never a blank card, never a silent 0.
Which one do I use?
| Situation | Do this | Bridge example |
|---|---|---|
| A list or table is loading | DataState with a skeleton of the same rows at the same height (DataTableSkeleton for tables) | Recent intros: four gray rows, each as tall as a real one |
| A number is loading or failed | StatBoxSkeleton while it loads; the error body if it fails. Never 0 | Outcomes → Intros tiles |
| Loaded, but nothing there yet | DataState's empty body: what will be here, and one action if there's an obvious next step | No intros yet. Intros you make or receive show up here. |
| A search finds nothing | Say "No results", not the empty message: the data is there, the search missed | Sharing → search for a name nobody has |
| The data failed to load | DataState's error body, inside the same frame, with Try again | Couldn't load intros |
| A button needs data that hasn't arrived | ButtonSkeleton at the button's size. An action that's already running uses the button's own spinner instead | Any action that waits on loaded data |
See it work
Live examples built from the package on the sample network. Click through them.
Each card loads on its own
The numbers arrive first, the intros later. Both titles show from the start, and nothing moves when the rows come in. Press Load again to watch it.
Your network
Recent intros
Empty, and no results
"Sharing with" has people: search for a name that isn't there to see No results. "Shared with you" has nobody yet, so it says what will appear.
Sharing
Failed: say so, in place, with Try again
This card fails the first time. The frame stays, the numbers don't turn into 0, and Try again loads it.
Your network
What to write
| Part | Rule | Example |
|---|---|---|
| Empty title | "No {things} yet", or what isn't there yet. | No intros yet |
| Empty line | What will show up here, or how to get some. One sentence. | Intros you make or receive show up here. |
| Empty action | One button, only when there's a clear next step. Name the action. | Make an intro |
| Search found nothing | "No results". Not the "yet" message. | No results |
| Error | "Couldn't load {thing}". Never a raw error message or a code. | Couldn't load intros |
| Retry | Always "Try again". | Try again |
Accessibility
- While a region loads, DataState sets
aria-busy, so screen readers wait for the content. Skeletons are hidden from them witharia-hidden. - The error body is an alert: it's read out when it appears.
- Changes between states are announced politely, without moving focus.
- Try again and the empty-state action are ordinary buttons, reached with Tab.
- The error isn't shown by color alone: it has an icon and words.
| Keys | What happens |
|---|---|
| Tab | Reaches Try again, or the empty-state action, when shown. |
| Enter / Space | Presses it. |
Components this uses
Each opens that component’s own page, with its options and code.
| Component | Its job here |
|---|---|
| DataState | Chooses the body: skeleton, empty, error or the data. Goes inside the card, under its title. |
| DataTable | DataTableSkeleton is a table's loading body, with the loaded row height. |
| StatBox | StatBoxSkeleton holds a number's place until it loads. |
| Button | The empty state's one action (primary); ButtonSkeleton for a button waiting on data; DataState draws Try again itself. |
| SearchInput | When it's in use, an empty list means "No results", not "nothing yet". |
Rules
The frame stays
A card never disappears because its data is missing. Loading, empty and failed are three bodies inside the same frame (icon, title and hint stay put), and a failure offers Try again. Returning null on error deletes the feature from the page: the person can't tell broken from empty.
Each part loads on its own
The page frame shows straight away; each card, table or list shows its own skeleton, then its data. Never hold the whole page behind one if (isLoading) return <Skeleton />: a slow part would block the fast ones.
Reserve the space
Nothing that arrives later may push content down. A skeleton has the same height and shape as what it stands for, measured, not guessed. A visible jump after the first paint is a bug.
Counts match what's shown
A number on screen comes from the same filtered list that renders the items, never from a separate count that can disagree. Never show a silent 0 when a count failed to load.
Where Bridge OS differs today
Follow this page, not the gap.
- Bridge OS cards write their own loading, empty and error bodies; they move onto DataState as Bridge OS adopts the package.
- Settings → Notifications draws nothing until its preferences load, and stays hidden if they fail (
profile-tab.tsx, NotificationPreferencesSection:if (!prefs) return null). - The sharing table's failure says "Failed to load" with "Retry"; it should say "Couldn't load …" with Try again (
sharing-table.tsx). - The Activity feed's failure says "Couldn't load activity" but offers no Try again, only "Try refreshing the page" (
activity-page-content.tsx). - The network settings Overview tab swaps the whole tab for one skeleton while it loads, instead of each card loading on its own (
overview-tab.tsx).
Sources
- Decision 2026-11-02 (#15 shared wrapper)
- Decision 2026-11-02 (#14)
- Bridge OS DESIGN.md → principle 7
- Bridge OS DESIGN.md → principle 5; ux-copywriter skill → Empty states
- Bridge OS DESIGN.md → principle 6; components.md
- components.md → Button / ButtonSkeleton
- Bridge OS
sharing-table.tsx(empty body: "No results" while searching, "Not sharing with anyone yet" otherwise) - DataState page → Accessibility and Behavior;
packages/ui/src/components/data-state.tsx - DESIGN.md → 2. Color
packages/ui/src/components/data-table.tsx; StatBox page → Do / Don't