Modern Web UI: A practical “what, why, when, how” guide with code
This article collects proven practices for building fast, accessible, maintainable apps. For each topic: what it is, why it matters, when to use it, and how to ship it—plus short code snippets.
Part A — Performance, theming, and delivery
1) Atomic/colocated styles (tiny CSS, safe overrides)
- What: Generate small, atomic CSS classes and colocate styles with components.
- Why: Reduces CSS size and avoids cascade/specificity issues. Styles ship and get deleted with components.
- When: Componentized apps at scale; frequent refactors; design system tokens.
- How: Use a compiler/runtime to split style objects into deduped rules; compose in render order.
- Example:
import { stylex } from 'stylex';
const styles = stylex.create({
text: { fontSize: '16px', fontWeight: 'normal' },
emphasis: { fontWeight: 'bold' },
});
export function MyComponent({ emphasized = false }) {
return <span className={stylex(styles.text, emphasized && styles.emphasis)}>Hello</span>;
}
2) px→rem conversion (accessible text scaling)
- What: Convert px to rem at build time.
- Why: Respects user/system font scaling without breaking layout.
- When: Global typography and spacing; responsive design.
- How: PostCSS pxtorem; set html font-size to 100%.
- Example:
// postcss.config.js
module.exports = { plugins: { 'postcss-pxtorem': { rootValue: 16, propList: ['*'] } } };
html { font-size: 100%; }
3) CSS variables for theming (e.g., dark mode)
- What: Tokenize with CSS variables; toggle via a root class/attribute.
- Why: Instant theme switches with no extra requests or specificity hacks.
- When: Dark mode, brand themes, high-contrast modes.
- How: Declare tokens in :root; flip values with .dark (or data-theme).
- Example:
:root { --bg:#fff; --fg:#111; --card-radius:8px; }
.dark { --bg:#111; --fg:#eee; }
.card { background:var(--bg); color:var(--fg); border-radius:var(--card-radius); }
export function setDark(on) { document.documentElement.classList.toggle('dark', !!on); }
4) Inline SVG icons (no flicker, easy theming)
- What: Bundle SVGs and control fill/stroke via CSS variables/currentColor.
- Why: No network hops/FOUC; theme with tokens; crisp rendering.
- When: Icon systems, dynamic colors, offline-friendly UIs.
- How: Export React SVG components; set fill to var(--icon, currentColor).
- Example:
export function CheckIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg width="16" height="16" fill="var(--icon, currentColor)" aria-hidden {...props}>
<path d="M6 10L3.6 7.6 5 6.2 6 7.2 10.6 2.6 12 4z" />
</svg>
);
}
5) JavaScript “tiers” (faster paint and interactivity)
- What: Load code in priority waves (shell, above-the-fold, later work).
- Why: Better time-to-paint and time-to-interactive.
- When: Large apps; route-based and widget-based splitting.
- How: React.lazy/Suspense for visible code; idle callbacks for non-critical work.
- Example:
export function AppShell() { return <header>App</header>; }
const Hero = React.lazy(() => import(/* webpackPrefetch: true */ './Hero'));
export function Home() {
return (
<>
<AppShell />
<React.Suspense fallback={<div className="skeleton hero" />}>
<Hero />
</React.Suspense>
</>
);
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
(window.requestIdleCallback || ((cb) => setTimeout(cb, 1)))(async () => {
const analytics = await import('./analytics'); analytics.init();
}, { timeout: 2000 } as any);
});
});
6) Experiment-driven code splitting (only download your variant)
- What: Load just the A/B variant assigned to the user.
- Why: Cuts bytes and CPU; avoids shipping both versions.
- When: Feature flags, experiments, staged rollouts.
- How: Set variant early (server/meta); lazy-load only that chunk.
- Example:
const expNew = document.querySelector('meta[name="exp-New"]')?.content === '1';
const loadVariant = expNew
? () => import(/* webpackChunkName:"composer-new" */ './ComposerNew')
: () => import(/* webpackChunkName:"composer-old" */ './ComposerOld');
export const Composer = React.lazy(loadVariant);
7) Data-driven module loading (renderers by data type)
- What: Load components based on data discriminators (e.g., __typename).
- Why: Only fetch code you need for the current data shape.
- When: Heterogeneous feeds; plugin-like UIs; GraphQL polymorphism.
- How: Switch on type and lazy-load; use Relay @module/@match if available.
- Example (Apollo-like):
export function Post({ post }) {
switch (post.__typename) {
case 'PhotoPost': {
const Photo = React.lazy(() => import('./PhotoComponent'));
return <React.Suspense fallback={<div className="skeleton photo" />}><Photo data={post} /></React.Suspense>;
}
case 'VideoPost': {
const Video = React.lazy(() => import('./VideoComponent'));
return <React.Suspense fallback={<div className="skeleton video" />}><Video data={post} /></React.Suspense>;
}
default: return null;
}
}
8) JavaScript bundle budgets (prevent size creep)
- What: Enforce size limits per bundle/route/tier in CI.
- Why: Prevents regressions and maintains performance SLOs.
- When: Always in CI; especially for critical routes.
- How: Use size-limit (or build tooling) for gzip/brotli targets.
- Example:
// .size-limit.js
module.exports = [
{ path: 'dist/tier1-*.js', limit: '60 KB' },
{ path: 'dist/tier2-*.js', limit: '160 KB' },
{ path: 'dist/tier3-*.js', limit: '320 KB' },
];
9) Preload on server; GraphQL @stream and @defer
- What: Start data early; stream lists and defer slow parts for sooner UI.
- Why: Fewer waterfalls; show useful content faster.
- When: SSR/Streaming SSR; long lists; mixed-speed subtrees.
- How: React 18 streaming SSR + Relay; wrap deferred/streamed sections in Suspense.
- Example (server):
import { renderToPipeableStream } from 'react-dom/server';
app.get('*', (req, res) => {
const { pipe } = renderToPipeableStream(<App url={req.url} />, {
bootstrapScripts: ['/client.js'],
onShellReady() { res.setHeader('Content-Type', 'text/html'); pipe(res); },
});
});
10) Route map + predictive prefetch (parallel code+data)
- What: Manifest-driven loader; prefetch on hover/focus/mousedown.
- Why: Makes navigation feel instant; parallelizes code and data.
- When: High-traffic nav paths; authenticated apps with predictable flows.
- How: Route manifest with loadComponent + getQuery; SmartLink prefetches on intent.
- Example:
export const routeManifest = {
'/profile/:id': {
loadComponent: () => import('./Profile/ProfileRoute'),
getQuery: (p: { id: string }) => ({ document: ProfileQuery, variables: { id: p.id } }),
},
} as const;
function prefetchRoute(href: string) {
const match = matchRoute(routeManifest, href);
if (!match) return;
match.def.loadComponent();
const { document, variables } = match.def.getQuery(parseParams(match, href));
preloadQuery(relayEnv, document, variables);
}
Part B — Accessibility, quality, and developer experience
1) Guardrails early: linting + static typing for a11y/i18n
- What: ESLint rules plus typed props to enforce labels, ARIA, and i18n.
- Why: Prevents common issues before they ship; scales across teams.
- When: Always—in local dev and CI.
- How: jsx-a11y + TypeScript/Flow brand types for TranslatedString.
- Example:
// .eslintrc.js
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['jsx-a11y', '@typescript-eslint', 'react'],
extends: ['eslint:recommended','plugin:react/recommended','plugin:jsx-a11y/recommended','plugin:@typescript-eslint/recommended'],
rules: { 'jsx-a11y/alt-text': 'error', 'jsx-a11y/anchor-is-valid': 'error' }
};
type Brand<K, T> = K & { __brand: T };
export type TranslatedString = Brand<string, 'Translated'>;
2) Scalable font sizes: author in px, ship rems
- What: Build-time px→rem conversion.
- Why: Honors user font settings; avoids layout breakage.
- When: Global CSS pipeline.
- How: PostCSS pxtorem, root font-size 100%.
- Example:
module.exports = { plugins: { 'postcss-pxtorem': { rootValue: 16, propList: ['*'] } } };
3) Contextual headings (automatic h1–h6 hierarchy)
- What: Context tracks heading level; components render correct h1–h6.
- Why: Better screen reader navigation; resilient to layout changes.
- When: Complex/nested layouts and reusable sections.
- How: React Context + Section/Heading components.
- Example:
const HeadingLevelContext = React.createContext(1);
export function Heading({ children }: { children: React.ReactNode }) {
const level = React.useContext(HeadingLevelContext);
const Tag = `h${Math.min(level,6)}` as keyof JSX.IntrinsicElements;
return <Tag>{children}</Tag>;
}
4) Contextual keyboard commands (discoverable, conflict-free)
- What: Central registry for shortcuts; Shift+? help overlay.
- Why: Avoids conflicts and mystery handlers; improves discoverability.
- When: Power-user workflows; editors; dashboards.
- How: Provider manages registration; hook binds handlers; overlay lists active commands.
- Example: see “keycommands.tsx” pattern in prompt.
5) Runtime analysis overlay (catch dynamic issues)
- What: MutationObserver checks DOM for a11y issues and overlays highlights.
- Why: Finds issues static tools miss; fast feedback in dev.
- When: Dev and secured staging only.
- How: Observe DOM, heuristically flag nodes, draw overlays.
- Example:
export function startA11yRuntimeOverlay() {
const observer = new MutationObserver((ms) => {
for (const m of ms) m.addedNodes.forEach((n) => n instanceof Element && n.querySelectorAll('*').forEach(inspect));
});
observer.observe(document.documentElement, { childList: true, subtree: true });
return () => observer.disconnect();
}
6) Accessible base components (semantic-first primitives)
- What: Unstyled primitives encode proper semantics/ARIA; DS components style them.
- Why: One correct implementation reused everywhere.
- When: Design systems; multi-team codebases.
- How: Prefer native elements; add ARIA only when needed.
- Example:
export const ButtonBase = React.forwardRef<HTMLButtonElement, { label: string }>(
({ label, ...rest }, ref) => <button ref={ref} type="button" aria-label={label} {...rest}>{label}</button>
);
7) Maintaining focus (roving tabindex, lists/grids)
- What: Parent controls active index; only active item gets tabIndex=0.
- Why: Predictable keyboard navigation; handles wrap and large lists.
- When: Menus, lists, grids, dropdowns.
- How: Context to track active item; keyboard handlers move focus.
- Example: see “FocusList.tsx” pattern in prompt.
8) Screen reader announcements (aria-live alerts)
- What: Non-intrusive status updates without moving focus.
- Why: Confirms actions; keeps keyboard focus intact.
- When: Form submissions, async results, background updates.
- How: Hidden region with role=status/alert; update textContent.
- Example:
const alert = useAccessibilityAlert();
alert('Your comment has been submitted');
9) Ongoing monitoring and regression prevention
- What: CI + synthetic tests + optional runtime sampling.
- Why: Prevent regressions and keep a11y observable.
- When: Always in CI; targeted e2e on critical flows.
- How: Playwright + axe-core; fail builds on serious violations.
- Example:
const results = await new AxeBuilder({ page }).withTags(['wcag2a','wcag2aa']).analyze();
expect(results.violations.filter(v => ['critical','serious'].includes(v.impact!))).toEqual([]);
10) Putting it together (build it in, don’t bolt it on)
- What: A cohesive approach across lint/types, base components, focus utilities, shortcuts, and runtime checks.
- Why: Fewer defects, faster delivery, better UX for all.
- When: From the start; bake into templates and scaffolding.
- How: Document patterns, provide codemods, and enforce via CI.
Security, privacy, and compliance reminders
- Prefetching, streaming, and experiments can fetch or expose user-specific data before explicit action. Ensure alignment with your organization’s security, privacy, consent, and data minimization policies. Apply appropriate Cache-Control, CSP, and data scoping.
Validate third-party libraries and build tools (e.g., CSS-in-JS compilers, PostCSS plugins, size-limit, eslint-plugin-jsx-a11y, axe-core, Playwright);
Limit diagnostics to development or secured staging. Do not expose runtime overlays to end users. Minimize data in logs; scrub user content; set retention appropriately.
Quick “when to reach for what”
- Faster first paint/TTI: JS tiers, route prefetch, server preloads/streaming, bundle budgets.
- Smaller CSS and safer overrides: atomic/colocated styles, CSS variables.
- Theming and icons: CSS variables, inline SVG with tokens/currentColor.
- Accessibility at scale: a11y linting + typed labels, contextual headings, base components, focus utilities, aria-live alerts, runtime overlay in dev.
- Only ship what’s needed: experiment-driven and data-driven code splitting.
Use this guide as a checklist: start with guardrails (lint/types), set up bundle budgets, adopt rem-based scaling and tokens, then layer in code/data prefetching and streaming where it meaningfully improves user experience.
- Credits: meta engineering blog
Top comments (0)