sf-lwc-ux
sf-lwc-ux: UX Patterns and Accessibility
Build Lightning Web Components with best-of-class user experience. Apply modern web UX patterns (inspired by Shadcn's composable philosophy) within the Salesforce platform, ensuring WCAG 2.1 AA accessibility.
Core Principles
- Composable over monolithic — small, focused components that slot together
- Every state has a UI — loading, empty, error, success, partial
- Keyboard-first — all interactions reachable without a mouse
- Progressive disclosure — show what's needed, reveal on demand
- Consistent feedback — users always know what happened and what to do next
Component Composition (Shadcn-Inspired)
Adopt the "open code, own your components" philosophy. Build a library of small, composable primitives rather than large all-in-one components.
Composition Hierarchy
Page Layout (grid/split/stack)
└── Section (card, panel, collapsible)
└── Content Block (list, table, form group)
└── Primitive (badge, pill, metric, avatar)
Slot-Based Composition
<!-- Parent: composes child components via slots -->
<template>
<c-section-card title="Customer Overview">
<c-metric-row slot="header-metrics"
label="Health Score" value={healthScore} variant="success">
</c-metric-row>
<c-contact-list slot="body" contacts={contacts}></c-contact-list>
<div slot="footer">
<lightning-button label="View All" onclick={handleViewAll}></lightning-button>
</div>
</c-section-card>
</template>
Component API Design
Follow consistent @api conventions across all components:
| Prop Pattern | Type | Purpose |
|---|---|---|
variant |
String | Visual style: "default", "success", "warning", "error", "info" |
size |
String | Dimensions: "small", "medium", "large" |
label |
String | Accessible visible text |
disabled |
Boolean | Disable interaction |
loading |
Boolean | Show loading state |
Layout Patterns
Card Grid (Dashboard)
Responsive card grid that adapts to container width using CSS-only (no JS resize observers).
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--slds-g-spacing-4, 1rem);
padding: var(--slds-g-spacing-4, 1rem);
}
Split View (Master-Detail)
.split-view {
display: grid;
grid-template-columns: 320px 1fr;
gap: var(--slds-g-spacing-4, 1rem);
height: 100%;
}
.split-view__list {
overflow-y: auto;
border-right: var(--slds-g-sizing-border-1) solid var(--slds-g-color-border-1, #e5e5e5);
}
.split-view__detail {
overflow-y: auto;
padding: var(--slds-g-spacing-4, 1rem);
}
Stacked Form
.form-stack {
display: flex;
flex-direction: column;
gap: var(--slds-g-spacing-4, 1rem);
max-width: 600px;
}
.form-stack__section {
padding: var(--slds-g-spacing-4, 1rem);
background: var(--slds-g-color-surface-1, #ffffff);
border-radius: var(--slds-g-radius-border-2, 0.25rem);
border: var(--slds-g-sizing-border-1) solid var(--slds-g-color-border-1, #e5e5e5);
}
.form-stack__actions {
display: flex;
justify-content: flex-end;
gap: var(--slds-g-spacing-3, 0.75rem);
padding-top: var(--slds-g-spacing-4, 1rem);
border-top: var(--slds-g-sizing-border-1) solid var(--slds-g-color-border-1, #e5e5e5);
}
Interaction Patterns
Loading States
Always show a loading indicator. Never leave the user staring at a blank screen.
<template>
<!-- Skeleton: structural placeholder while data loads -->
<template if:true={isLoading}>
<div class="skeleton-card">
<div class="skeleton-line skeleton-line--title"></div>
<div class="skeleton-line skeleton-line--body"></div>
<div class="skeleton-line skeleton-line--body skeleton-line--short"></div>
</div>
</template>
<!-- Content: rendered after data arrives -->
<template if:false={isLoading}>
<div class="content-card">
<h2>{title}</h2>
<p>{description}</p>
</div>
</template>
</template>
.skeleton-line {
height: var(--slds-g-spacing-3, 0.75rem);
background: var(--slds-g-color-surface-container-2, #f3f3f3);
border-radius: var(--slds-g-radius-border-1, 0.125rem);
margin-bottom: var(--slds-g-spacing-2, 0.5rem);
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-line--title {
width: 40%;
height: var(--slds-g-spacing-4, 1rem);
}
.skeleton-line--short { width: 60%; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
Empty States
Provide guidance, not just "No records found."
<template>
<template if:true={isEmpty}>
<div class="empty-state" role="status">
<lightning-icon icon-name="utility:info" size="large"></lightning-icon>
<h3 class="empty-state__title">{emptyTitle}</h3>
<p class="empty-state__message">{emptyMessage}</p>
<lightning-button
if:true={showAction}
label={emptyActionLabel}
variant="brand"
onclick={handleEmptyAction}>
</lightning-button>
</div>
</template>
</template>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--slds-g-spacing-3, 0.75rem);
padding: var(--slds-g-spacing-8, 2rem) var(--slds-g-spacing-4, 1rem);
text-align: center;
color: var(--slds-g-color-on-surface-2, #444444);
}
.empty-state__title {
font-size: var(--slds-g-font-size-5, 1rem);
font-weight: var(--slds-g-font-weight-6, 600);
color: var(--slds-g-color-on-surface-1, #181818);
}
Error Boundaries
Catch and display errors gracefully. Never show raw error text to users.
<template>
<template if:true={hasError}>
<div class="error-boundary" role="alert">
<lightning-icon icon-name="utility:error" variant="error" size="small"></lightning-icon>
<div class="error-boundary__content">
<p class="error-boundary__title">Something went wrong</p>
<p class="error-boundary__detail">{userFriendlyError}</p>
<lightning-button label="Try Again" onclick={handleRetry} variant="neutral">
</lightning-button>
</div>
</div>
</template>
</template>
.error-boundary {
display: flex;
gap: var(--slds-g-spacing-3, 0.75rem);
padding: var(--slds-g-spacing-4, 1rem);
background: var(--slds-g-color-error-container-1, #fef1f1);
border: var(--slds-g-sizing-border-1) solid var(--slds-g-color-border-error-1, #ea001e);
border-radius: var(--slds-g-radius-border-2, 0.25rem);
}
Toast / Inline Notifications
Use lightning/platformShowToastEvent for transient feedback. Use inline alerts for persistent messages.
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
handleSuccess() {
this.dispatchEvent(new ShowToastEvent({
title: 'Record Saved',
message: 'Account has been updated successfully.',
variant: 'success'
}));
}
Optimistic Updates
Update the UI immediately, then reconcile with the server response.
async handleToggleFavorite() {
const previousState = this._isFavorite;
this._isFavorite = !this._isFavorite; // optimistic
try {
await toggleFavorite({ recordId: this.recordId });
} catch (error) {
this._isFavorite = previousState; // rollback
this.dispatchEvent(new ShowToastEvent({
title: 'Error',
message: 'Could not update favorite status.',
variant: 'error'
}));
}
}
Micro-Interactions
Focus Rings
All interactive elements must have visible focus indicators.
:host {
--focus-ring-color: var(--slds-g-color-accent-1, #0176d3);
--focus-ring-offset: 2px;
}
.interactive:focus-visible {
outline: 2px solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
border-radius: var(--slds-g-radius-border-1, 0.125rem);
}
Hover States
.list-item {
transition: background-color 150ms ease;
}
.list-item:hover {
background-color: var(--slds-g-color-surface-3, #f3f3f3);
}
.list-item:active {
background-color: var(--slds-g-color-accent-container-1, #e5f0fb);
}
Transitions
Use short, purposeful transitions. Avoid decorative animation.
.expandable {
overflow: hidden;
max-height: 0;
transition: max-height 200ms ease-out;
}
.expandable--open {
max-height: 500px;
}
Accessibility (WCAG 2.1 AA)
ARIA Checklist
| Requirement | Implementation |
|---|---|
| Interactive elements labeled | aria-label or visible <label> on every input/button |
| Dynamic content announced | role="status" or role="alert" on live regions |
| Custom widgets have roles | role="tablist", role="tab", role="tabpanel" |
| Expanded/collapsed state | aria-expanded="true/false" on toggles |
| Loading announced | aria-busy="true" on container during load |
| Disabled communicated | aria-disabled="true" + visual indicator |
Keyboard Navigation
| Pattern | Keys | Behavior |
|---|---|---|
| Tab order | Tab / Shift+Tab | Move through interactive elements in DOM order |
| Action | Enter / Space | Activate buttons, links, checkboxes |
| List navigation | Arrow Up/Down | Move through list items, menu options |
| Tab panel | Arrow Left/Right | Switch between tabs |
| Escape | Esc | Close modal, dismiss popover, cancel action |
handleKeyDown(event) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.focusNextItem();
break;
case 'ArrowUp':
event.preventDefault();
this.focusPreviousItem();
break;
case 'Enter':
case ' ':
event.preventDefault();
this.selectCurrentItem();
break;
case 'Escape':
this.closeDropdown();
break;
default:
break;
}
}
Focus Management
handleOpenModal() {
this.isModalOpen = true;
// After render, move focus to the modal's first focusable element
// eslint-disable-next-line @lwc/lwc/no-async-operation
setTimeout(() => {
const firstFocusable = this.template.querySelector('[data-first-focus]');
if (firstFocusable) firstFocusable.focus();
}, 0);
}
handleCloseModal() {
this.isModalOpen = false;
// Return focus to the trigger element
const trigger = this.template.querySelector('[data-modal-trigger]');
if (trigger) trigger.focus();
}
Color Contrast
Use SLDS accessible color palettes (--slds-g-color-*-base-*) to guarantee 4.5:1 contrast ratio for normal text and 3:1 for large text. Never rely on color alone to convey meaning — always pair with icons or text labels.
Responsive Design in LWC
LWC shadow DOM prevents global media queries from targeting component internals. Use these CSS-only patterns:
Container-Based Sizing
.responsive-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--slds-g-spacing-4, 1rem);
}
Flexible Wrapping
.metric-row {
display: flex;
flex-wrap: wrap;
gap: var(--slds-g-spacing-3, 0.75rem);
}
.metric-item {
flex: 1 1 150px;
min-width: 0;
}
Clamp for Typography
.hero-title {
font-size: clamp(
var(--slds-g-font-size-5, 1rem),
4vw,
var(--slds-g-font-size-9, 1.75rem)
);
}
Scoring Rubric (100 Points)
| Category | Points | Pass Criteria |
|---|---|---|
| State Management | 20 | All states covered: loading, empty, error, success, partial data |
| Accessibility | 25 | ARIA labels, keyboard nav, focus management, screen reader support |
| Layout Quality | 15 | Responsive grid/flex, no fixed pixel widths, proper spacing scale |
| Interaction Design | 15 | Hover/focus/active states, transitions, toasts for feedback |
| Component Composition | 15 | Slot-based, consistent @api, composable hierarchy |
| Responsive | 10 | Works across viewport sizes using CSS-only techniques |
Scoring Guide
- 90-100: Exceptional UX — all states, fully accessible, responsive
- 70-89: Good UX — minor missing states or accessibility gaps
- 50-69: Needs improvement — missing loading/empty states, weak keyboard support
- Below 50: Major UX issues — no error handling, inaccessible
Cross-Skill Integration
| Skill | Relationship |
|---|---|
| sf-lwc | Provides JS patterns (wire, Apex, events) that this skill's UX patterns wrap |
| sf-lwc-design | Provides the SLDS 2 hooks used in all CSS examples above |
| sf-lwc-styling | Provides utility classes that implement these UX patterns efficiently |
Dependencies
- Target org with LWC support (API 45.0+)
- SLDS 2 recommended (API 62.0+) for full styling hook support
sfCLI authenticated