Ir al contenido

Componentes · Ola 4 · Editorial

Reading progress

Una línea de 2 px que crece mientras se lee: sigue el desplazamiento de la página o el de un artículo, arriba de la ventana con la cabecera que se va o en el borde de la cabecera fija. Es decorativa y sigue al desplazamiento cuadro a cuadro, sin transición: se mueve solo cuando se mueve la página. El avance va en el color de selección sobre una pista de 2 px en border-subtle.

import { ReadingProgress } from '@archeblack/ui';

Ejemplos

Con la cabecera que se va

La página de un post de Hermes: AppShell sin sticky y la barra en progress con placement="viewport" (por defecto). Al desplazar, la cabecera sube con la página y la barra queda sola arriba de la ventana, sobre su pista, midiendo el artículo. Desplaza el marco.

Svelte
<script lang="ts">
	import {
		AppShell,
		AppShellLink,
		Container,
		Dialog,
		Eyebrow,
		Highlight,
		IndexList,
		IndexListItem,
		Input,
		Masthead,
		Prose,
		ReadingProgress
	} from '@archeblack/ui';
	import { essay } from '../../reading-progress/examples/text.ts';

	// En un producto, la página actual sale de la URL. En la página de un post ningún link lleva
	// current, como en Hermes: un post no es ninguna de las secciones de la navegación.
	const links = [
		{ href: '#inicio', label: 'Inicio' },
		{ href: '#publicaciones', label: 'Publicaciones' },
		{ href: '#temas', label: 'Temas' }
	];
	const footerLinks = [
		{ href: '#publicaciones', label: 'Publicaciones' },
		{ href: '#temas', label: 'Temas' },
		{ href: '#autores', label: 'Autores' },
		{ href: '#sobre', label: 'Sobre' }
	];
	const posts = [
		{ title: 'El Alma Pide Locura', slug: 'el-alma-pide-locura', date: '2026-07-27T21:45:16Z' },
		{
			title: 'Lo que cambia cuando alguien escucha',
			slug: 'lo-que-cambia-cuando-alguien-escucha',
			date: '2026-05-03T01:33:44Z'
		},
		{
			title: 'El Daimon en la Máquina',
			slug: 'el-daimon-en-la-maquina',
			date: '2026-01-28T12:00:00Z'
		},
		{
			title: 'El Doctor que Bailó con el Tiempo',
			slug: 'el-doctor-que-bailo-con-el-tiempo',
			date: '2026-01-17T12:00:00Z'
		},
		{
			title: 'Lo Numinoso en CONTROL',
			slug: 'lo-numinoso-en-control',
			date: '2026-01-04T12:00:00Z'
		}
	];

	let article = $state<HTMLElement | null>(null);
	let searching = $state(false);
	let query = $state('');

	/** Sin mayúsculas ni tildes, la misma regla que usa Highlight para marcar. */
	const fold = (text: string) => text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLocaleLowerCase();
	const term = $derived(fold(query.trim()));
	const found = $derived(term ? posts.filter((post) => fold(post.title).includes(term)) : []);

	// ⌘K (Mac) o Ctrl+K abre y cierra la búsqueda: el atajo lo escucha el producto. AppShell solo lo
	// muestra en el botón (searchShortcut) y lo declara en aria-keyshortcuts.
	function onkeydown(event: KeyboardEvent) {
		if (event.key.toLowerCase() !== 'k' || event.altKey || event.shiftKey) return;
		const mac = /Mac|iPhone|iPad/.test(navigator.userAgent);
		if (!(mac ? event.metaKey : event.ctrlKey)) return;
		event.preventDefault();
		searching = !searching;
	}
</script>

<svelte:window {onkeydown} />

<!-- En el producto, data-product va en <html> (src/app.html): la capa del menú y la de búsqueda se
     abren en un portal y toman el mismo acento. La cabecera se va con la página (sin sticky): la de
     una página de lectura. La barra de lectura, en progress con placement="viewport" (por defecto),
     queda fija arriba de la ventana. -->
<AppShell
	mark="hermes"
	homeHref="#inicio"
	width="page"
	menu="fullscreen"
	searchHref="#buscar"
	searchShortcut="K"
	onSearch={() => (searching = true)}
>
	{#snippet navigation()}
		{#each links as link (link.href)}
			<AppShellLink href={link.href}>{link.label}</AppShellLink>
		{/each}
	{/snippet}

	{#snippet progress()}
		<ReadingProgress target={article} />
	{/snippet}

	{#snippet footer()}© 2026 Hermes{/snippet}

	{#snippet footerNavigation()}
		{#each footerLinks as link (link.href)}
			<AppShellLink href={link.href}>{link.label}</AppShellLink>
		{/each}
	{/snippet}

	<!-- La página de un post, recortada: la cabecera del texto y el artículo, que mide la barra. -->
	<Container style="padding-top: var(--arche-spacing-12)">
		<Masthead
			title="El Alma Pide Locura"
			dek="Hay caminos fuera del jardín. Sobre la sed que el saber no calma, y la disposición que el alma estaba pidiendo."
		>
			{#snippet eyebrow()}
				<Eyebrow topic="Filosofía" href="#filosofia" date="2026-07-27T21:45:16Z" />
			{/snippet}
		</Masthead>
	</Container>
	<Container width="reading" style="margin-top: var(--arche-spacing-12)">
		<article bind:this={article}>
			<Prose html={essay} />
		</article>
	</Container>
</AppShell>

<!-- La capa de búsqueda: el producto la abre con onSearch o con el atajo. La x queda al final de la
     columna de página, donde está «Buscar ⌘K»; el campo y los resultados, en la columna de lectura,
     centrada. -->
<Dialog bind:open={searching} size="fullscreen" title="Buscar">
	<div
		style="display: grid; gap: var(--arche-spacing-6); width: min(100%, var(--arche-layout-width-reading)); margin-inline: auto"
	>
		<Input
			size="xl"
			type="search"
			bind:value={query}
			aria-label="Buscar en Hermes"
			placeholder="Busca por título…"
			autocomplete="off"
			suffix={term ? `${found.length} ${found.length === 1 ? 'resultado' : 'resultados'}` : ''}
			liveSuffix
		/>
		{#if found.length > 0}
			<IndexList variant="listing" aria-label="Resultados" onclick={() => (searching = false)}>
				{#each found as post (post.slug)}
					<IndexListItem href={`#${post.slug}`} date={post.date}>
						{#snippet title()}<Highlight text={post.title} {query} />{/snippet}
					</IndexListItem>
				{/each}
			</IndexList>
		{/if}
	</div>
</Dialog>

En la cabecera fija, midiendo el artículo

placement="header" la pone en el borde de la cabecera, con la pista sobre su línea, y target mide solo el artículo: se llena al terminar el texto, antes del colofón. Desplaza el panel.

La mesa

Una reunión de eruditos. Hombres sentados alrededor de una mesa con sus méritos sobre la madera, hablando, midiendo, sosteniendo cada afirmación con su prueba. No hay impostura en la escena. Cada uno hizo el trabajo: leyó, revisó, se corrigió, aprendió a no afirmar más de lo que podía demostrar. Y aun así, cuando vuelven a casa por la noche, algo sigue golpeando la puerta.

Conviene decirlo antes que nada, porque de otro modo todo lo que sigue se malinterpreta. La mesa no está equivocada.

Aprendimos a mirar así por razones buenas. La exigencia de prueba antes de creer no nació de la arrogancia. Nació contra la peste tratada con oración, contra el dogma que no admitía revisión, contra el que vendía milagros a quien no tenía con qué comprarlos. Fue una defensa, y funcionó. Nos dio medicina, métodos, la posibilidad de corregirnos a nosotros mismos. Nadie en su sano juicio querría devolverla.

Hay una sed ahí que el saber no calma. Y no lo va a hacer nunca. No porque sea poco, sino porque es de otro orden.

La silla ocupada

Lo que cambia no es lo que hay en la casa. Es qué decides reconocer.

Sueñas todas las noches. Cada tanto una certeza aparece entera, antes de que exista una sola razón que la sostenga. Las coincidencias ocurren con una frecuencia de la que nadie lleva registro. Nada de eso pide permiso ni espera su turno: está ahí, ocurriendo, desde mucho antes de que decidieras nada.

El reflejo entrenado es pasar de largo sin mirar. Se archiva como ruido, como resto del día, como casualidad, y se sigue caminando. Pedirle a un sueño su bibliografía no es rigor, es error de categoría. Es pedirle a un mapa que te diga cómo huele el bosque, y concluir que el bosque no huele.

Eso que el alma pedía era locura. No una pose romántica, ni una ruptura actuada, ni desprecio del intelecto. Solo la voluntad de mirar lo que todavía no puedes explicar sin exigirle la explicación de entrada.

Publicado
27 jul 2026
Tema
Filosofía
Svelte
<script lang="ts">
	import '@archeblack/ui/products/hermes.css';
	import {
		DescriptionList,
		DescriptionListItem,
		Logo,
		Prose,
		ReadingProgress
	} from '@archeblack/ui';
	import { essay } from './text.ts';

	// El panel hace de página: en un producto, sin container, la barra sigue a la ventana.
	let panel = $state<HTMLElement | null>(null);
	let article = $state<HTMLElement | null>(null);
</script>

<!-- La barra va en el borde de la cabecera fija (placement="header": su contenedor posicionado) y
     mide solo el artículo (target): llega al 100 % al terminar el texto, antes del colofón. El panel
     se desplaza con el teclado: es una región con nombre y tabindex. -->
<!-- Una región que se desplaza toma el foco con Tab para desplazarse con el teclado (WCAG 2.1.1). -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
	bind:this={panel}
	data-product="hermes"
	role="region"
	aria-label="Post de ejemplo"
	tabindex="0"
	style="width: 100%; height: 26rem; overflow-y: auto; background: var(--arche-color-bg); border: 1px solid var(--arche-color-border-subtle)"
>
	<header
		style="position: sticky; top: 0; z-index: 1; display: flex; align-items: center; min-height: 3.5rem; padding-inline: var(--arche-spacing-4); background: var(--arche-color-surface); border-bottom: 1px solid var(--arche-color-border-subtle)"
	>
		<Logo name="hermes" layout="header" tone="white" />
		<ReadingProgress placement="header" container={panel} target={article} />
	</header>
	<div style="padding: var(--arche-spacing-8) var(--arche-spacing-4)">
		<article bind:this={article}>
			<Prose html={essay} />
		</article>
		<DescriptionList layout="inline" style="margin-top: var(--arche-spacing-16)">
			<DescriptionListItem term="Publicado" description="27 jul 2026" />
			<DescriptionListItem term="Tema" description="Filosofía" />
		</DescriptionList>
	</div>
</div>

Todo el texto

Sin target, mide todo lo que se desplaza. container apunta a un panel en lugar de a la página.

La mesa

Una reunión de eruditos. Hombres sentados alrededor de una mesa con sus méritos sobre la madera, hablando, midiendo, sosteniendo cada afirmación con su prueba. No hay impostura en la escena. Cada uno hizo el trabajo: leyó, revisó, se corrigió, aprendió a no afirmar más de lo que podía demostrar. Y aun así, cuando vuelven a casa por la noche, algo sigue golpeando la puerta.

Conviene decirlo antes que nada, porque de otro modo todo lo que sigue se malinterpreta. La mesa no está equivocada.

Aprendimos a mirar así por razones buenas. La exigencia de prueba antes de creer no nació de la arrogancia. Nació contra la peste tratada con oración, contra el dogma que no admitía revisión, contra el que vendía milagros a quien no tenía con qué comprarlos. Fue una defensa, y funcionó. Nos dio medicina, métodos, la posibilidad de corregirnos a nosotros mismos. Nadie en su sano juicio querría devolverla.

Hay una sed ahí que el saber no calma. Y no lo va a hacer nunca. No porque sea poco, sino porque es de otro orden.

La silla ocupada

Lo que cambia no es lo que hay en la casa. Es qué decides reconocer.

Sueñas todas las noches. Cada tanto una certeza aparece entera, antes de que exista una sola razón que la sostenga. Las coincidencias ocurren con una frecuencia de la que nadie lleva registro. Nada de eso pide permiso ni espera su turno: está ahí, ocurriendo, desde mucho antes de que decidieras nada.

El reflejo entrenado es pasar de largo sin mirar. Se archiva como ruido, como resto del día, como casualidad, y se sigue caminando. Pedirle a un sueño su bibliografía no es rigor, es error de categoría. Es pedirle a un mapa que te diga cómo huele el bosque, y concluir que el bosque no huele.

Eso que el alma pedía era locura. No una pose romántica, ni una ruptura actuada, ni desprecio del intelecto. Solo la voluntad de mirar lo que todavía no puedes explicar sin exigirle la explicación de entrada.

Svelte
<script lang="ts">
	import '@archeblack/ui/products/hermes.css';
	import { Prose, ReadingProgress } from '@archeblack/ui';
	import { essay } from './text.ts';

	let panel = $state<HTMLElement | null>(null);
</script>

<!-- Sin target, la barra mide todo lo que se desplaza: aquí el panel entero (en un producto, la
     página). En una página sin cabecera fija va arriba de la ventana con placement="viewport";
     dentro de este panel, un contenedor sticky de 1 px la sostiene arriba con placement="header". -->
<!-- Una región que se desplaza toma el foco con Tab para desplazarse con el teclado (WCAG 2.1.1). -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
	bind:this={panel}
	data-product="hermes"
	role="region"
	aria-label="Texto de ejemplo"
	tabindex="0"
	style="position: relative; width: 100%; height: 20rem; overflow-y: auto; background: var(--arche-color-bg); border: 1px solid var(--arche-color-border-subtle)"
>
	<div style="position: sticky; top: 0; z-index: 1; height: 1px">
		<ReadingProgress placement="header" container={panel} />
	</div>
	<div style="padding: var(--arche-spacing-6) var(--arche-spacing-4)">
		<Prose html={essay} />
	</div>
</div>

En la página de un post

En un producto, la barra sigue a la ventana: sin container. Va una sola vez, en la página del post, y mide su <article>. Dónde va depende de la cabecera del producto: las dos configuraciones son parte del sistema.

Con la cabecera que se va: arriba de la ventana

Para un producto de lectura (el patrón editorial): AppShell sin sticky y la barra con placement="viewport", el valor por defecto. Queda fija en top: 0, de lado a lado y en z-index-sticky; al desplazar, la cabecera sube con la página y la barra, con su pista, es lo único que queda arriba. Va en el snippet progress del AppShell, solo en la página del post.

svelte
<script lang="ts">	import { AppShell, ReadingProgress } from '@archeblack/ui';	let article = $state<HTMLElement | null>(null);</script><!-- Sin sticky: la cabecera sube con la página y la barra queda sola arriba de la ventana. --><AppShell mark="hermes" homeHref="/" width="page" menu="fullscreen">	{#snippet progress()}		<ReadingProgress target={article} />	{/snippet}	<article bind:this={article}>…</article></AppShell>

Con la cabecera fija: en su borde inferior

Para una app (AppShell sticky): placement="header" dentro de la cabecera, en el snippet progress. La barra se apoya en su borde inferior y su pista tapa la línea de 1 px. Una cabecera propia tiene que ser su contenedor posicionado (position: sticky o relative).

svelte
<!-- La cabecera fija es el contenedor posicionado de la barra. --><AppShell product="Consola" homeHref="/" sticky>	{#snippet progress()}		<ReadingProgress placement="header" target={article} />	{/snippet}	<article bind:this={article}>…</article></AppShell>

Props

Props de ReadingProgress
PropDescripción
target? HTMLElement | null El elemento cuya lectura se mide, por ejemplo el <article> del post: la barra está vacía cuando su inicio llega a la barra (bajo la cabecera) y llena cuando su final llega al pie de la ventana. Sin target mide todo lo que se desplaza.
container? HTMLElement | null El elemento que se desplaza, si no es la página: un panel o una columna con overflow: auto. Sin container, la barra sigue a la ventana.
placement? 'viewport' | 'header' Por defecto 'viewport'viewport: fija arriba de la ventana, de lado a lado, en la capa de las cabeceras fijas (z-index-sticky): la barra de la cabecera que se va. header: en el borde inferior de su contenedor posicionado (la cabecera fija), de lado a lado y 1 px por debajo, con la pista sobre su línea.
class? ClassValue Clases del producto; se suman a arche-reading-progress.
...rest HTMLAttributes<HTMLDivElement> id, style y data-* van a la raíz. aria-hidden lo pone el componente: la barra es decorativa.

Accesibilidad

  • Es decorativa: va con aria-hidden="true", sin rol ni nombre. Quien usa un lector de pantalla ya sabe dónde está en el texto, y una barra de progreso que cambia con cada desplazamiento sería ruido. No reemplaza a una tabla de contenidos ni al tiempo de lectura.
  • No es un Progress: ese es un role="progressbar" para una tarea. Esta solo acompaña la lectura.
  • Se mueve solo cuando la persona desplaza la página, y al mismo tiempo: no tiene transición ni animación propias. Con movimiento reducido no hay nada que apagar.
  • No agrega trabajo a cada evento de desplazamiento: el evento pide un cuadro de requestAnimationFrame y la barra se mide como mucho una vez por cuadro. Se dibuja con transform, que no vuelve a maquetar la página.
  • En el servidor sale vacía y no toca window: empieza a medir al hidratar.
  • El color es el de la selección con su brillo (selected y glow-selected), como el relleno de Progress y la barra del link actual de AppShell, sobre una pista de 2 px en border-subtle. En colores forzados, el avance se pinta en Highlight y la pista en GrayText.
  • Con viewport, la página suma 8 px de scroll-padding-top: lo que el navegador lleva a la vista (un ancla, un control enfocado con Tab) queda debajo de la barra, con su anillo de foco entero (WCAG 2.4.11). Con la cabecera fija de AppShell manda el alto de la cabecera.

Qué evitar

  • Una barra de lectura en páginas que no son de lectura (un listado, un formulario). Solo en la página de un post o de un texto largo.
  • Medir toda la página cuando al final del post hay mucho más (comentarios, relacionados). target en el <article>: la barra se llena al terminar el texto.
  • Otra pista o un fondo propio debajo de la barra. La pista del componente: 2 px en border-subtle, que con header tapa la línea de la cabecera.
  • La barra arriba de la ventana con una cabecera fija, o en la cabecera cuando la cabecera se va. viewport con la cabecera que se va (una página de lectura) y header con la cabecera fija (una app).
  • Anunciar el avance a los lectores de pantalla o mostrar el porcentaje. La barra sola, decorativa. Si el tiempo importa, el tiempo de lectura en la firma.