HTML, CSS y JavaScript no son solo "los tres pilares". Son una arquitectura en capas: el DOM como infraestructura, CSSOM como sistema de presentación, y JS como la capa de comportamiento que une ambos. Cuando entiendes cada capa en profundidad, los frameworks se vuelven opcionales.
HTML semántico y accesibilidad
ARIA y roles implícitos
Cada elemento HTML tiene un rol ARIA implícito que los lectores de pantalla usan:
<nav> → rol="navigation"
<main> → rol="main"
<button> → rol="button"
<!-- No dupliques los roles implícitos -->
<nav role="navigation"> ❌ redundanteUsa ARIA solo cuando el HTML nativo no cubre el patrón:
<div role="tablist">
<button role="tab" aria-selected="true">Pestaña 1</button>
</div><dialog> — modales sin hacks
<dialog id="modal">
<form method="dialog">
<p>¿Confirmar acción?</p>
<button value="cancel">Cancelar</button>
<button value="confirm">Aceptar</button>
</form>
</dialog>
<script>
document.querySelector("dialog").showModal()
</script>Nativa, accesible, sin overlay manual, sin z-index battles, sin focus trapping manual.
Shadow DOM y Web Components
class Counter extends HTMLElement {
static observedAttributes = ["value"]
#shadow = this.attachShadow({ mode: "open" })
#value = 0
constructor() {
super()
this.#shadow.innerHTML = `
<style>button { padding: 0.5em; }</style>
<button id="dec">-</button>
<span id="val">0</span>
<button id="inc">+</button>
`
this.#shadow.querySelector("#inc").onclick = () => this.#update(1)
this.#shadow.querySelector("#dec").onclick = () => this.#update(-1)
}
#update(delta) {
this.#value += delta
this.#shadow.querySelector("#val").textContent = this.#value
this.dispatchEvent(new CustomEvent("change", { detail: this.#value }))
}
}
customElements.define("x-counter", Counter)Encapsulamiento: CSS interno no filtra al exterior, IDs internos no chocan con el documento.
CSS moderno (2026)
Cascade layers — el fin de la guerra de especificidad
@layer base, components, utilities;
@layer base {
a { color: blue; }
}
@layer components {
.card a { color: inherit; } /* gana a base aunque sea más específico */
}
@layer utilities {
.text-red { color: red; } /* gana a todo */
}El orden de las @layer define la precedencia. La especificidad dentro de una misma capa todavía importa, pero entre capas gana la última declarada.
Container queries
.card-container {
container-type: inline-size;
container-name: card;
}
@container card (width > 600px) {
.card { display: flex; gap: 2rem; }
.card-image { width: 40%; }
}No es @media — es responsive a nivel de componente, no de viewport.
:has() — el selector padre
/* Estilo aplicado al padre basado en hijos */
form:has(input:invalid) {
border-color: red;
}
.card:has(img:hover) {
transform: scale(1.02);
}
/* Grid que se adapta al contenido */
.grid:has(> :nth-child(3)) {
grid-template-columns: repeat(3, 1fr);
}View Transitions API
document.startViewTransition(() => {
document.querySelector("#content").innerHTML = newContent
})Transiciones de página nativas, sin librerías de animación. Control total con pseudo-elementos ::view-transition-old y ::view-transition-new.
Propiedades lógicas
.card {
margin-inline: auto;
padding-block: 1rem;
border-inline-start: 3px solid blue;
}Funcionan en cualquier dirección de escritura (LTR, RTL, vertical). No más margin-left que rompe en árabe.
Rendimiento real
- LCP (Largest Contentful Paint): Causado por imágenes sin
fetchpriority, fuentes lentas, CSS blocking. - INP (Interaction to Next Paint): Causado por JS síncrono en main thread, listeners lentos, layout thrashing.
- CLS (Cumulative Layout Shift): Causado por imágenes sin dimensiones, ads dinámicos, web fonts sin
font-display.
<img src="hero.webp" width="1200" height="600" loading="lazy" decoding="async" fetchpriority="high" />
<link rel="preload" href="font.woff2" as="font" crossorigin />
<link rel="preconnect" href="https://api.example.com" />JavaScript en el navegador
Event delegation
document.querySelector("#list").addEventListener("click", (e) => {
const btn = e.target.closest("[data-action]")
if (!btn) return
const action = btn.dataset.action
if (action === "delete") deleteItem(btn.dataset.id)
})Un solo listener en vez de N. Esencial para listas dinámicas.
AbortController
function search(query, { signal }) {
const controller = new AbortController()
signal?.addEventListener("abort", () => controller.abort())
return fetch(`/api/search?q=${query}`, {
signal: controller.signal
})
}
// Uso: cancela fetch previo si el usuario escribe más
let controller
input.addEventListener("input", () => {
controller?.abort()
controller = new AbortController()
search(input.value, { signal: controller.signal })
})Web Workers — offload de cómputo
// main.js
const worker = new Worker("hash.worker.js", { type: "module" })
worker.postMessage(fileBuffer)
worker.onmessage = (e) => console.log("Hash:", e.data)
// hash.worker.js
self.onmessage = async (e) => {
const hash = await crypto.subtle.digest("SHA-256", e.data)
self.postMessage(hash)
}Seguridad frontend
- CSP:
Content-Security-Policy: script-src 'self'bloquea XSS por inyección inline. - Trusted Types: Previene
innerHTMLcomo string. Solo permite a través de un policy. - DOMPurify: Sanitiza HTML desconocido. Úsalo antes de
innerHTMLsi es inevitable. - Sandbox iframe:
<iframe sandbox="allow-scripts">— sin formularios, sin navegación, sin plugins.
Ejemplo completo: buscador progresivo
<form role="search" id="search-form">
<label for="q">Buscar:</label>
<input type="search" id="q" name="q" autocomplete="off" />
<ul id="results" role="listbox" aria-live="polite"></ul>
</form>#results:empty { display: none; }
#results li { padding: 0.5em; cursor: pointer; }
#results li[aria-selected="true"] { background: highlight; }const input = document.querySelector("#q")
const results = document.querySelector("#results")
let controller, selectedIndex = -1
input.addEventListener("input", async () => {
controller?.abort()
controller = new AbortController()
selectedIndex = -1
if (input.value.length < 2) { results.innerHTML = ""; return }
const res = await fetch(`/api/search?q=${input.value}`, {
signal: controller.signal
})
const data = await res.json()
results.innerHTML = data.map(r => `<li role="option">${r.title}</li>`).join("")
})
input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault()
const items = results.querySelectorAll("li")
items[selectedIndex]?.removeAttribute("aria-selected")
selectedIndex = (selectedIndex + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length
items[selectedIndex]?.setAttribute("aria-selected", "true")
}
})Sin framework. Accesible. Rendimiento nativo. La web platform es poderosa por sí sola. Domínala antes de delegar en abstracciones.