Building Accessible and High-Performance Frontend Components
Table of Contents
- Keyboard Navigation with
tabindex
- Inline Editing with
contenteditable
- Array Transforms & Hidden O(n²) Trap
- Dynamic Code Eval (
Function
vseval
) - Case Study: Drag‑and‑Drop Text Selection
- Accessibility & Performance Pitfalls in Common Components
- Testing & Validation Toolbox
- Secure Sandbox Runner (Next.js + Cross‑Origin iframe)
- Local & Production Origin Setup
- Troubleshooting iframe Messaging & CSP
- Conclusion
- Further Reading
1 · Keyboard Navigation with tabindex
Browsers place only native controls (links, buttons, form fields) in the tab order.
Use tabindex
sparingly:
<!-- semantic button — needs no tabindex -->
<button onclick="save()">Save</button>
<!-- custom widget behaves like a button -->
<div role="button" tabindex="0" onclick="save()">Save</div>
| Value | Meaning |
|-------|---------|
| 0
| Add element to natural tab order |
| -1
| Programmatically focusable, not tabbable |
| 1…
| Avoid – breaks logical navigation |
Best practices
- Prefer semantic elements
- For custom widgets add
role
+tabindex="0"
- Keep DOM order logical
- Audit with Lighthouse / aXe
2 · Inline Editing with contenteditable
<div contenteditable="true" role="textbox" aria-label="Notes" tabindex="0">
Rich text here…
</div>
Pros: zero‑effort rich text.
Cons: cross‑browser quirks, screen‑reader announcement issues, XSS risk.
Guidelines:
- Use only for rich‑text; prefer
<textarea>
for plain text - Add
role="textbox"
, use libraries (Slate/Quill) for production - Sanitize HTML with DOMPurify
3 · Array Transforms – Hidden O(n²) Trap
// ❌ quadratic
const map = items.reduce((a, x) => ({ ...a, [x.id]: x.val }), {});
Copying the accumulator each turn → O(n²) slowdown.
Linear alternatives
const map = Object.fromEntries(items.map(i => [i.id, i.val]));
4 · Dynamic Code Eval (Function
vs eval
)
Both execute arbitrary strings; both blocked by CSP unless 'unsafe-eval'
.
Function()
cannot see local scope but still accesses globalThis
.
Safer alternatives: JSON.parse, templates, sandboxed interpreters, strict CSP.
5 · Case Study: Drag‑and‑Drop Text Selection
Read‑only contenteditable
wrapper stops accidental drag when user selects text.
<div draggable="true" class="card">
<div contenteditable="true" role="document" aria-readonly="true"
tabindex="-1" spellcheck="false"
onkeydown="return false" onpaste="return false">
<RichTextComponent/>
</div>
</div>
6 · Accessibility & Performance Pitfalls in Common Components
| Component | Pitfall | Fix |
|-----------|---------|-----|
| Forms | Placeholder as label | Use real <label>
/ aria-label
|
| Modals | Focus not trapped | Focus container, add aria-modal
|
| Tables | No <th>
/<caption>
| Add headers +
caption |
| Tooltips | title
only | Real tooltip + aria-describedby
|
| Dropdowns | No keyboard nav | Up/Down/Esc, toggle aria-expanded
|
| DnD | No keyboard alt | Provide buttons + live‑region |
7 · Testing & Validation Toolbox
- Lighthouse, aXe, WAVE
- NVDA, VoiceOver, TalkBack
- Chrome DevTools Performance
- CI: aXe‑core / pa11y
8 · Secure Sandbox Runner (Next.js + Cross‑Origin iframe)
8.1 Architecture
Host page ──postMessage──▶ sandbox iframe (sandbox="allow-scripts")
◀──result────
Iframe lives on different origin (sandbox.yourapp.com
) with CSP allowing 'unsafe-eval'
.
8.2 Minimal sandbox (run.html
)
<!doctype html>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self' 'unsafe-eval';">
<script src="./run.js" defer></script>
run.js
self.onmessage = ({ data }) => {
const { uid, code, input } = data;
let result, error = false;
try {
// eslint-disable-next-line no-new-func
const fn = new Function('input', `"use strict";\n${code}`);
result = fn(input);
} catch (e) {
error = true;
result = e instanceof Error ? e.message : String(e);
}
parent.postMessage({ uid, result, error }, '*');
};
8.3 Host page (client component)
'use client';
const iframeRef = useRef<HTMLIFrameElement|null>(null);
useEffect(() => {
const ifr = document.createElement('iframe');
ifr.sandbox.add('allow-scripts'); // opaque origin
ifr.src = `${SANDBOX_ORIGIN}/run.html`;
document.body.appendChild(ifr);
iframeRef.current = ifr;
}, []);
function runUserCode(code: string, input: any) {
const uid = crypto.randomUUID();
iframeRef.current?.contentWindow?.postMessage({ uid, code, input }, '*'); // '*' needed
}
Use e.source === iframe.contentWindow
to validate replies.
9 · Local & Production Origin Setup
9.1 Local Dev
/etc/hosts
127.0.0.1 sandbox.localhost
- Next dev:
next dev -p 3000 --hostname 0.0.0.0
- Iframe URL:
http://sandbox.localhost:3000/sandbox/run.html
9.2 Production on Vercel
| Project | Root Dir | Domain |
|---------|----------|--------|
| main‑app | /
| yourapp.com
|
| sandbox | /public/sandbox
| sandbox.yourapp.com
|
sandbox
project vercel.json
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'unsafe-eval';"
}
]
}
]
}
10 · Troubleshooting iframe Messaging & CSP
| Symptom | Cause | Fix |
|---------|-------|-----|
| onmessage
never fires | Used specific origin but iframe has opaque origin (sandbox="allow-scripts"
) | Post with '*'
or add allow-same-origin
|
| ERR_SSL_PROTOCOL_ERROR
| Using https://
but local server is HTTP | Switch to http://
or create self‑signed HTTPS (mkcert) |
| Refused to evaluate ... 'unsafe-eval'
| Sandbox CSP missing 'unsafe-eval'
| Add in sandbox CSP (not main app) |
| No logs visible | DevTools context on top frame | Right‑click › Inspect inside iframe or open run.html
in new tab |
11 · Conclusion
By combining solid accessibility patterns, performance‑first coding, and a hardened sandbox for untrusted code:
- Apps stay inclusive (WCAG‑compliant)
- Critical paths remain fast (no hidden O(n²))
- Untrusted user code runs safely out‑of‑origin
12 · Further Reading
- WAI‑ARIA Authoring Practices
- WCAG 2.2
- MDN:
tabindex
,contenteditable
, CSP - DOMPurify
- vm2 (Node) / SES (browser)