UI/UX Consistency Guidelines
This document provides comprehensive guidelines for maintaining UI/UX consistency across Waldur HomePort. Following these patterns ensures a predictable, accessible, and professional user experience.
Table of Contents
- Empty States
- Button Visibility (Hide vs Disable)
- Loading States
- Tables and Filters
- Dialogs and Confirmations
- Notifications
- Status Indicators
- Tooltips
- Typography and Content
- Accessibility
- Responsive Behavior
- Anti-Patterns
- Report Filters
- Chart Composition
- Report Page Layout
- Prices and Computed Totals
1. Empty States
Empty states are critical touchpoints that can either frustrate users or guide them toward productive actions. Never leave users at dead ends.
1.1 Empty State Types
| Type | Purpose | Example |
|---|---|---|
| First-use | Onboarding opportunity | "No projects yet. Create your first project to get started." |
| No search results | Help refine search | "Your search 'xyz' did not match any resources." |
| Filtered empty | Suggest filter modification | "No resources matching current filters" |
| User-cleared | Task completion | "All tasks completed!" |
| Error state | Recovery with retry | "Unable to load data." + Reload button |
1.2 Table Empty States
Use the NoResult component for all table empty states:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | |
Message hierarchy: Title → Explanation → CTA (call-to-action)
Utility functions (from src/table/utils.tsx):
1 2 3 4 5 6 7 8 9 10 11 12 | |
1.3 Inline Empty Values
Standard: Use DASH_ESCAPE_CODE (—) for null/undefined values in displays:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
For arrays:
1 2 3 4 5 | |
What the column holds decides it, not how empty the cell looks.
An empty cell is rarely a null: a count is 0, a price is 0.0000000, a relation is []. Deciding per cell is how one table ends up saying nothing three different ways, so decide by type:
| The column holds | Empty renders as | Example |
|---|---|---|
| money — a price, a cost, a total | the figure, always: defaultCurrency(...) |
€0.00 for a plan whose components are all priced at 0 |
| a count | the number, or the column's own word for zero | Not used for the resources on a plan |
| an optional list or relation, where empty is the ordinary state | — |
a plan's organization groups: none assigned means no restriction |
a genuinely absent field (null/undefined) |
— via renderFieldOrDash |
a description nobody wrote |
A price, a count or a total is never dashed: 0 is a fact somebody's
configuration produced and usually one they must act on, while — reads as
"does not apply". Money columns print defaultCurrency(...) unconditionally
for the same reason, and PlanComponentsTable spells it out where a plan's
zero prices are listed.
Where the two conventions collide — the app-wide idiom for an unconfigured
value is muted text (FieldRow, WaldurResourcesList), while a neighbouring
column in the same table states its zero at full weight — match the table.
Cells read against the ones beside them before they read against the app.
1.4 Empty State Message Templates
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
2. Button Visibility (Hide vs Disable)
The decision to hide vs disable a button significantly impacts user experience. Use this decision matrix consistently.
2.1 Decision Matrix
| Scenario | Action | Rationale |
|---|---|---|
| User lacks permission (role-based) | HIDE | User will never be authorized in current context |
| Resource in wrong state | DISABLE + tooltip | Temporary; user can fix by changing state |
| Action in progress | DISABLE + spinner | Will become available when complete |
| Feature not applicable | HIDE | Doesn't apply to this resource type |
| Validation incomplete | DISABLE + tooltip | User can complete requirements |
| Quota exceeded | DISABLE + tooltip | User can request more quota |
2.2 Disabled Button Requirements
ALWAYS provide a tooltip explaining WHY the button is disabled.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | |
Visual requirements:
- Disabled buttons use design token colors (e.g.,
text-muted,btn-disabled) - NOT opacity - Opacity is reserved for overlays only; components use solid colors for predictability, accessibility, and theming
- Keep the same width to prevent layout shift
- Use
aria-disabledfor accessibility
2.3 Permission Patterns
Use hasPermission() utility consistently:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
Staff-only actions:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
3. Loading States
Consistent loading feedback prevents user confusion and maintains perceived performance.
3.1 Table Loading
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
3.2 Button Loading
Use the pending prop on buttons:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
Key behaviors:
- Button shows spinner and becomes disabled
- Button width stays stable (no layout shift)
- Label remains visible next to spinner
3.3 Error States
Use LoadingErred component for recoverable errors:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
Always provide a retry action - never leave users stuck.
4. Tables and Filters
4.1 Filter Visibility Rules
| Filter Position | When Visible | On Empty Table |
|---|---|---|
header |
Always | Always visible |
menu |
Toggle button click | Show toggle button |
sidebar |
When filters active OR toggled | Show toggle button (allow discovery) |
Important: Never completely hide filters on empty tables. Users need to discover that filters exist and may be causing the empty state.
When filters return no results, show a specific empty state:
- Message: "No results match your filters"
- Actions: "Clear filters" / "View filters"
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
1 2 3 4 5 6 | |
4.2 Filter Behavior Checklist
- Clear visual indication when filters are active (badge count)
- Prominent "Clear all" functionality
- Reset to page 1 when filters change
- Persist filter state across navigation (when appropriate)
- Show "No results matching filters" message (not generic empty)
4.3 Pagination Rules
1 2 3 4 5 6 7 8 9 10 11 12 | |
5. Dialogs and Confirmations
5.1 Confirmation and Mutation Pattern
The preferred way to handle mutations that require user confirmation is the useManagedMutation hook. It centralizes confirmation logic, loading states, and notifications.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | |
5.2 Batch Mutation Pattern
For operations involving multiple items (bulk actions), use the useBatchMutation hook. It handles partial successes gracefully and standardizes bulk confirmation dialogs.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | |
5.2 Form Dialog Pattern
Use the useModal hook to manage dialog state:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | |
6. Notifications
6.1 Standard Mutation Pattern
For most API actions, use useManagedMutation even if no confirmation is required. This ensures consistent loading state and notification handling.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
6.2 Benefits of Managed Mutations
- Declarative Logic: Focus on the action and messages rather than managing
try/catchblocks and loading states manually. - UX Consistency: Standardizes how confirmations look and how success/error notifications are displayed.
- Robustness:
useBatchMutationhandles partial failures (usingPromise.allSettled) ensuring the user knows exactly what succeeded and what failed. - Automatic Sync: Integrated support for
refetchand query invalidation ensures the UI stays up to date after the mutation. - Type Safety: Fully typed hooks reduce runtime errors when passing variables or handling results.
For non-component usage (utility functions, services), use NotifyService:
1 2 3 4 5 | |
Configuration:
- Duration: 7000ms (7 seconds)
- Position: top-right
- Dismissible: Yes (show dismiss button)
7. Status Indicators
7.1 StateIndicator Component
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | |
7.2 Variant Mapping Guidelines
| State Category | Variant | Examples |
|---|---|---|
| Success/Active | success |
Active, Running, Completed, Approved |
| Warning/Pending | warning |
Pending, Processing, Updating |
| Error/Failed | danger |
Error, Failed, Rejected, Unavailable |
| Neutral/Default | default |
Draft, Archived, Paused, Unknown |
| Info | info |
New, In Review |
Custom variants (for differentiation within same category):
pink, blue, teal, indigo, purple, rose, orange, moss
8. Tooltips
8.1 Usage Guidelines
Use tooltips for:
- Disabled buttons: Explain why disabled (required)
- Icon-only buttons: Always provide tooltip describing the action (required)
- Truncated text: Show full text
- Icons without labels: Describe the element's purpose
- Complex terms: Provide definitions
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
8.2 Disabled Element Tooltip Pattern
From ActionItem - use question icon for disabled action explanation:
1 2 3 4 5 6 7 8 9 10 11 12 | |
9. Typography and Content
9.1 Text Truncation
1 2 3 4 5 6 7 8 9 10 11 12 | |
9.2 Internationalization
All user-facing text must use translate():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
10. Accessibility
10.1 Disabled State Accessibility
1 2 3 4 5 6 7 8 9 10 11 | |
10.2 Keyboard Navigation
- All interactive elements must be keyboard accessible
- Use proper focus management in modals
- Maintain logical tab order
10.3 Screen Reader Support
1 2 3 4 5 6 7 8 9 10 | |
11. Responsive Behavior
11.1 Breakpoints
1 2 3 4 5 6 7 8 | |
11.2 Filter Position Adaptation
1 2 3 4 | |
11.3 Interactive Element Sizing
Form controls (inputs, selects, textareas):
- Minimum height: 40px for adequate touch/click area
Buttons:
- Three standard sizes: 44px (large), 36px (default), 28px (small)
- All sizes are acceptable for desktop interfaces
- Use size appropriate to context and hierarchy
Icon-only buttons:
- Should maintain adequate click area even with small icons
- Consider padding to reach at least 28px hit area
1 2 3 4 | |
Note: The 44px minimum touch target (WCAG) is primarily for mobile/touch interfaces. Desktop applications can use smaller interactive elements.
12. Anti-Patterns
12.1 Anonymous User Actions
When an action requires authentication (e.g., deploying a resource, ordering a service), show a confirmation dialog explaining that login is required, rather than silently redirecting or doing nothing.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | |
Key rules:
- Never silently redirect anonymous users — always explain what's happening
- Use
confirmfromuseModal()hook with a clear title and descriptive body - Label the positive button with the action ("Log in"), not generic "OK"
- If the element is normally a
<Link>, render a<button>for anonymous users to prevent navigation before the dialog
12.2 Equal Card Heights in Flex Containers
When cards are displayed in a row (carousel, grid), ensure they all have equal height regardless of content differences (description length, tags, badges).
1 2 3 4 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
Checklist:
- Flex container parent: default
align-items: stretch(don't override) - Item wrapper:
display: flexto propagate stretch - Card link/wrapper:
height: 100% - Card element:
h-100class - Variable content area:
flex-grow-1to fill remaining space
What NOT to Do
| Anti-Pattern | Problem | Correct Approach |
|---|---|---|
Redundant length === 0 checks |
Table already handles empty | Trust the Table component |
Mixed null display (—, "N/A", "", "None") |
Inconsistent | Always use DASH_ESCAPE_CODE or renderFieldOrDash |
| Hide + Disable for same scenario | Confusing | Follow decision matrix consistently |
| Disabled button without tooltip | User doesn't know why | Always provide tooltip |
| Hard-coded strings | Not translatable | Always use translate() |
| Hidden filters on empty tables | Can't discover filters | Show filter toggle |
| Empty state without CTA | Dead end | Always provide next action |
user.is_staff checks everywhere |
Inconsistent | Use hasPermission() utility |
| Silent redirect for anonymous users | Confusing, "magical" | Show confirmation dialog before redirect |
Cards without h-100 in flex rows |
Uneven card heights | Use display: flex on wrapper + h-100 on card |
Code Examples - Bad vs Good
1 2 3 4 5 6 7 8 9 | |
1 2 3 4 5 6 7 | |
1 2 3 4 5 6 7 | |
1 2 3 4 5 6 7 8 9 10 11 | |
Quick Reference
Key Imports
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | |
Decision Trees
Should I hide or disable this button?
1 2 3 4 5 6 | |
What empty state should I show?
1 2 3 4 5 6 7 | |
Top 10 Inconsistencies to Fix
Based on a codebase analysis, these are prioritized inconsistencies that should be addressed:
1. Mixed Null/Empty Display Values
Files affected: src/vmware/PortsList.tsx, src/project/manage/ProjectGeneral.tsx, src/user/hooks/HooksList.tsx, and others
Problem: Using || 'N/A' instead of renderFieldOrDash()
1 2 3 4 5 6 7 8 | |
2. Scattered Staff Permission Checks
Files affected: Multiple files with direct user.is_staff checks
Problem: Permission checks done inconsistently across components
1 2 3 4 5 | |
3. Sidebar Filters Hidden on Empty Tables
Files affected: src/table/Table.tsx:301-323
Problem: Sidebar filters only show when filtersStorage.length > 0, preventing filter discovery
1 2 3 4 | |
4. Disabled Buttons Without Tooltips
Various components have disabled buttons that don't explain why they're disabled.
Fix: Audit all disabled props and ensure accompanying tooltip prop
5. Empty Copy Field Values
Files affected: src/proposals/manage/CallProposalsList.tsx, src/openstack/openstack-tenant/TenantPortsList.tsx
Problem: Using || '' for copy fields can result in copying empty string
1 2 3 4 5 | |
6. Inconsistent Empty State Messages
Various list components show plain text instead of using the NoResult component.
Fix: All list empty states should use NoResult with appropriate messaging
7. Mixed Boolean Permission Returns
Files affected: src/permissions/hasPermission.ts
Problem: Function returns true or undefined instead of true or false
1 2 3 4 5 6 | |
8. Invitation Display Inconsistencies
Files affected: src/invitations/join-organization/submission.tsx
Problem: Using || 'N/A' in user-facing messages
1 2 3 4 | |
9. Form Field Empty Fallbacks
Files affected: Various form components using || ''
Problem: Inconsistent handling of empty form values
10. Missing Error State Handling
Various data-fetching components don't show LoadingErred on fetch failure.
Fix: Audit all data-fetching components for proper error state handling
13. Report Filters
When to use which filter pattern
| Pattern | Use |
|---|---|
| Page-level filters (top of page) | Used on report pages |
| Header global filters | Used as system-wide filters affecting multiple pages |
| Table dropdown filters | Used for table column filtering |
Standard filter component composition
There is no fixed standard set — filters vary per report depending on the dataset.
Filters used in reports:
- Organization
- Project
- Date range (start–end)
Optional shortcut selectors that populate the date range:
- 7 days
- 30 days
- 1 year
Other filters depend on the report dataset.
Filter visibility rules
Filters are visible at the top of the page. Even when result data is empty, filters remain visible so the user can adjust them to change the dataset.
Fix: Always render filter components unconditionally — never gate them on data state or loading.
1 2 3 4 5 6 7 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
Mobile behavior
Report filters remain at the top of the page. On smaller screens filters may wrap to multiple rows, and remain visible above charts and tables. Filters must stay accessible without opening a separate panel.
Default filter states per report type
| Default state | Meaning |
|---|---|
| No filters applied | Report loads full dataset |
| Pre-filled date range | Report loads recent data (e.g. last 30 days) |
| Required filters empty | User must select filters before results appear |
14. Chart Composition
When to use each chart type
| Chart | Use |
|---|---|
| Donut / Pie | Showing how something breaks down as a share of the whole |
| Bar | Comparing values across categories |
| Line | Showing how something changes over time |
| Stacked bar | Comparing totals AND showing what's inside each total |
- Use line when continuity matters — when the shape of the trend is the point.
- Use stacked bar only when both the total and the breakdown are meaningful. Keep it to 4–5 segments max, otherwise it gets hard to read.
- Avoid donut/pie when you have more than 5 segments or when users need to compare values precisely — a bar chart does that job better.
Chart-to-filter binding
Charts must reflect the same filtered dataset as the report. Filters affect charts and tables simultaneously. There is no separate filter state for charts.
Fix: Charts must receive already-filtered data from the parent report, not manage filters themselves.
1 2 3 4 5 6 7 8 9 10 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | |
Data provenance display
Charts should include:
- Title describing the metric
- Totals or summary values
- Legend
Tooltips show the specific value and aggregation context (e.g. Sum of invoices in March — $12,400).
Chart empty and loading states
| State | UI |
|---|---|
| Loading | Chart skeleton |
| Empty | "No data for selected filters. Try adjusting your filters." |
| Error | Inline message or alert |
Keep the chart container at its normal height even when empty. Never return null from a chart component.
Fix: Always render the chart container. Show an empty state when there is no data.
1 2 3 4 5 6 7 8 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
Responsive chart behavior
Charts should resize to container width and remain readable without horizontal scroll.
15. Report Page Layout
Standard page structure
Page title → Report filters → Charts → Data table.
Fix: Always follow this order — never render a chart or table before filters.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | |
Spacing
16px between blocks.
State label placement
Use existing label hierarchy from current mocks. Mocks must follow the same placement used in existing pages. Do not introduce new state layouts.
16. Prices and Computed Totals
Never make the reader do the arithmetic
If a screen shows charges that add up to something the user cares about, show the sum. A list of line items with no total is an unfinished screen.
Where the total goes, relative to its line items
The job the number does decides the placement:
- The total drives a choice — a catalogue price, a plan comparison, anything the user reads in order to pick. The total leads: it is the first thing in its column or block, and the line items below explain how it was reached.
- The total confirms a calculation — an order summary, an invoice, a cart. The total follows: the user has already read the line items and the sum closes them out.
Either way the total and its line items are on the same screen. A total behind a tab, an accordion or a dialog is a total the decision was made without.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
A floor must be labelled as a floor
When the figure is only what the user pays before sizing anything, or before
metered usage, say so in the label — Starting price, From €20.00 — not in
a footnote below the fold. Price: €20.00 next to components charged per unit is
a wrong number, not a rounded one.
Comparable options go side by side
Plans, tiers and any other set of alternatives the user is choosing between are
laid out in parallel columns, never in tabs. Two options that are never on screen
together cannot be compared. Reference:
src/marketplace/offerings/details/PlanComparison.tsx.
Price belongs next to the primary action
A page whose primary call to action is a purchase or a request shows the entry
price adjacent to that button, with a link into the full breakdown. Reference:
src/marketplace/offerings/details/OfferingPriceSummary.tsx.
Labels must not undercut what is on screen
Do not name an action in a way that implies the visible content is partial —
Download full price list next to a price list tells the reader that the list
they are looking at is not the full one. Name the format or the destination
instead: Export price list.
Implementation Checklist
When fixing these inconsistencies:
- Run
yarn lint:checkafter changes - Verify no TypeScript errors with
yarn build - Test empty states manually
- Verify disabled button tooltips appear
- Check responsive behavior on mobile
- Run relevant unit tests