Skip to content
SalesforceSkills

User Experience

Makes your app feel polished with loading states, empty screens, error messages, and keyboard support.

Composable over monolithicEvery state has a UIKeyboard-firstProgressive disclosureConsistent feedback

Skill Details

Install this skill

Versionv1.1.0AuthorJorge ArteagaLicenseMITSections10

Works with

Claude CodeCursorWindsurf

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

1
Composable over monolithic — small, focused components that slot together
2
Every state has a UI — loading, empty, error, success, partial
3
Keyboard-first — all interactions reachable without a mouse
4
Progressive disclosure — show what's needed, reveal on demand
5
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

HTML
class="code-comment"><!-- Parent: composes child components via slots -->
<class="code-tag">template>
    <class="code-tag">c-section-card title="Customer Overview">
        <class="code-tag">c-metric-row slot="header-metrics"
            label="Health Score" value={healthScore} variant="success">
        </class="code-tag">c-metric-row>
        <class="code-tag">c-contact-list slot="body" contacts={contacts}></class="code-tag">c-contact-list>
        <class="code-tag">div slot="footer">
            <class="code-tag">lightning-button label="View All" onclick={handleViewAll}></class="code-tag">lightning-button>
        </class="code-tag">div>
    </class="code-tag">c-section-card>
</class="code-tag">template>

Component API Design

Follow consistent @api conventions across all components:

Layout Patterns

Card Grid (Dashboard)

Responsive card grid that adapts to container width using CSS-only (no JS resize observers).

CSS
.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)

CSS
.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

CSS
.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.

HTML
<class="code-tag">template>
    class="code-comment"><!-- Skeleton: structural placeholder while data loads -->
    <class="code-tag">template if:true={isLoading}>
        <class="code-tag">div class="skeleton-card">
            <class="code-tag">div class="skeleton-line skeleton-line--title"></class="code-tag">div>
            <class="code-tag">div class="skeleton-line skeleton-line--body"></class="code-tag">div>
            <class="code-tag">div class="skeleton-line skeleton-line--body skeleton-line--short"></class="code-tag">div>
        </class="code-tag">div>
    </class="code-tag">template>

    class="code-comment"><!-- Content: rendered after data arrives -->
    <class="code-tag">template if:false={isLoading}>
        <class="code-tag">div class="content-card">
            <class="code-tag">h2>{title}</class="code-tag">h2>
            <class="code-tag">p>{description}</class="code-tag">p>
        </class="code-tag">div>
    </class="code-tag">template>
</class="code-tag">template>
CSS
.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."

HTML
<class="code-tag">template>
    <class="code-tag">template if:true={isEmpty}>
        <class="code-tag">div class="empty-state" role="status">
            <class="code-tag">lightning-icon icon-name="utility:info" size="large"></class="code-tag">lightning-icon>
            <class="code-tag">h3 class="empty-state__title">{emptyTitle}</class="code-tag">h3>
            <class="code-tag">p class="empty-state__message">{emptyMessage}</class="code-tag">p>
            <class="code-tag">lightning-button
                if:true={showAction}
                label={emptyActionLabel}
                variant="brand"
                onclick={handleEmptyAction}>
            </class="code-tag">lightning-button>
        </class="code-tag">div>
    </class="code-tag">template>
</class="code-tag">template>
CSS
.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.

HTML
<class="code-tag">template>
    <class="code-tag">template if:true={hasError}>
        <class="code-tag">div class="error-boundary" role="alert">
            <class="code-tag">lightning-icon icon-name="utility:error" variant="error" size="small"></class="code-tag">lightning-icon>
            <class="code-tag">div class="error-boundary__content">
                <class="code-tag">p class="error-boundary__title">Something went wrong</class="code-tag">p>
                <class="code-tag">p class="error-boundary__detail">{userFriendlyError}</class="code-tag">p>
                <class="code-tag">lightning-button label="Try Again" onclick={handleRetry} variant="neutral">
                </class="code-tag">lightning-button>
            </class="code-tag">div>
        </class="code-tag">div>
    </class="code-tag">template>
</class="code-tag">template>
CSS
.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.

JavaScript
import { ShowToastEvent } from class="code-string">'lightning/platformShowToastEvent';

handleSuccess() {
    this.dispatchEvent(new ShowToastEvent({
        title: class="code-string">'Record Saved',
        message: class="code-string">'Account has been updated successfully.',
        variant: class="code-string">'success'
    }));
}

Optimistic Updates

Update the UI immediately, then reconcile with the server response.

JavaScript
async handleToggleFavorite() {
    const previousState = this._isFavorite;
    this._isFavorite = !this._isFavorite; class="code-comment">// optimistic

    try {
        await toggleFavorite({ recordId: this.recordId });
    } catch (error) {
        this._isFavorite = previousState; class="code-comment">// rollback
        this.dispatchEvent(new ShowToastEvent({
            title: class="code-string">'Error',
            message: class="code-string">'Could not update favorite status.',
            variant: class="code-string">'error'
        }));
    }
}

Demo Polish Patterns

Patterns that make demos and PoCs feel production-ready with minimal effort.

Skeleton-to-Content Reveal

The most impressive loading pattern for demos — content appears to "paint in":

JavaScript
async connectedCallback() {
    this.isLoading = true;

    const data = await loadData();

    class="code-comment">// Brief delay makes the skeleton visible (for demo effect)
    class="code-comment">// Remove this delay in production
    await new Promise(resolve => setTimeout(resolve, 600));

    this._data = data;
    this.isLoading = false;
}

Progressive Data Loading

Load sections independently so the page feels alive:

JavaScript
async connectedCallback() {
    class="code-comment">// Fire all requests in parallel, render each as it arrives
    this.loadMetrics();
    this.loadTimeline();
    this.loadRelatedRecords();
}

async loadMetrics() {
    this.metricsLoading = true;
    this.metrics = await getMetrics({ recordId: this.recordId });
    this.metricsLoading = false;
}

async loadTimeline() {
    this.timelineLoading = true;
    this.timeline = await getTimeline({ recordId: this.recordId });
    this.timelineLoading = false;
}

async loadRelatedRecords() {
    this.relatedLoading = true;
    this.relatedRecords = await getRelated({ recordId: this.recordId });
    this.relatedLoading = false;
}

"Before and After" Pattern

Show the old way vs the new way in a single component:

HTML
<class="code-tag">template>
    <class="code-tag">div class="before-after">
        <class="code-tag">div class="before-after__panel">
            <class="code-tag">div class="before-after__label before-after__label--before">Before</class="code-tag">div>
            <class="code-tag">div class="before-after__content">
                class="code-comment"><!-- Show the old, clunky process -->
                <class="code-tag">p>15 clicks across 4 screens</class="code-tag">p>
                <class="code-tag">p>Average time: 8 minutes</class="code-tag">p>
            </class="code-tag">div>
        </class="code-tag">div>
        <class="code-tag">div class="before-after__divider">
            <class="code-tag">lightning-icon icon-name="utility:forward" size="small"></class="code-tag">lightning-icon>
        </class="code-tag">div>
        <class="code-tag">div class="before-after__panel">
            <class="code-tag">div class="before-after__label before-after__label--after">After</class="code-tag">div>
            <class="code-tag">div class="before-after__content">
                class="code-comment"><!-- Show the new, streamlined process -->
                <class="code-tag">p>2 clicks, 1 screen</class="code-tag">p>
                <class="code-tag">p>Average time: 30 seconds</class="code-tag">p>
            </class="code-tag">div>
        </class="code-tag">div>
    </class="code-tag">div>
</class="code-tag">template>

Instant Feedback on Actions

For demos, make every action feel responsive:

JavaScript
async handleSave() {
    class="code-comment">// Immediately show saving state
    this.isSaving = true;
    this.saveButtonLabel = class="code-string">'Saving...';

    try {
        await saveRecord({ record: this.record });

        class="code-comment">// Brief success state before resetting
        this.saveButtonLabel = class="code-string">'Saved!';
        this.saveButtonVariant = class="code-string">'success';

        this.dispatchEvent(new ShowToastEvent({
            title: class="code-string">'Success',
            message: class="code-string">'Record saved successfully.',
            variant: class="code-string">'success'
        }));

        await new Promise(resolve => setTimeout(resolve, 1000));
    } finally {
        this.isSaving = false;
        this.saveButtonLabel = class="code-string">'Save';
        this.saveButtonVariant = class="code-string">'brand';
    }
}

Micro-Interactions

Focus Rings

All interactive elements must have visible focus indicators.

CSS
: 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

CSS
.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.

CSS
.expandable {
    overflow: hidden;
    max-height: 0;
    transition: max-height 200ms ease-out;
}

.expandable--open {
    max-height: 500px;
}

Accessibility (WCAG 2.1 AA)

ARIA Checklist

Keyboard Navigation

JavaScript
handleKeyDown(event) {
    switch (event.key) {
        case class="code-string">'ArrowDown':
            event.preventDefault();
            this.focusNextItem();
            break;
        case class="code-string">'ArrowUp':
            event.preventDefault();
            this.focusPreviousItem();
            break;
        case class="code-string">'Enter':
        case class="code-string">' ':
            event.preventDefault();
            this.selectCurrentItem();
            break;
        case class="code-string">'Escape':
            this.closeDropdown();
            break;
        default:
            break;
    }
}

Focus Management

JavaScript
handleOpenModal() {
    this.isModalOpen = true;
    class="code-comment">// After render, move focus to the modalclass="code-string">'s first focusable element
    class="code-comment">// eslint-disable-next-line @lwc/lwc/no-async-operation
    setTimeout(() => {
        const firstFocusable = this.template.querySelector('[data-first-focus]class="code-string">');
        if (firstFocusable) firstFocusable.focus();
    }, 0);
}

handleCloseModal() {
    this.isModalOpen = false;
    class="code-comment">// 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

CSS
.responsive-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
    gap: var(--slds-g-spacing-4, 1rem);
}

Flexible Wrapping

CSS
.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

CSS
.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)Reference

Cross-Skill IntegrationReference

Prop PatternTypePurpose
variantStringVisual style: "default", "success", "warning", "error", "info"
sizeStringDimensions: "small", "medium", "large"
labelStringAccessible visible text
disabledBooleanDisable interaction
loadingBooleanShow loading state
RequirementImplementation
Interactive elements labeledaria-label or visible on every input/button
Dynamic content announcedrole="status" or role="alert" on live regions
Custom widgets have rolesrole="tablist", role="tab", role="tabpanel"
Expanded/collapsed statearia-expanded="true/false" on toggles
Loading announcedaria-busy="true" on container during load
Disabled communicatedaria-disabled="true" + visual indicator
PatternKeysBehavior
Tab orderTab / Shift+TabMove through interactive elements in DOM order
ActionEnter / SpaceActivate buttons, links, checkboxes
List navigationArrow Up/DownMove through list items, menu options
Tab panelArrow Left/RightSwitch between tabs
EscapeEscClose modal, dismiss popover, cancel action
CategoryPointsPass Criteria
State Management20All states covered: loading, empty, error, success, partial data
Accessibility25ARIA labels, keyboard nav, focus management, screen reader support
Layout Quality15Responsive grid/flex, no fixed pixel widths, proper spacing scale
Interaction Design15Hover/focus/active states, transitions, toasts for feedback
Component Composition15Slot-based, consistent @api, composable hierarchy
Responsive10Works across viewport sizes using CSS-only techniques
SkillRelationship
sf-lwc-designProvides the SLDS 2 hooks used in all CSS examples above
sf-lwc-stylingProvides utility classes that implement these UX patterns efficiently
sf-se-demo-scriptsDemo Polish patterns create impressive demo experiences
sf-lwc-motionAnimations bring loading/transition states to life

Navigate User Experience