Componentes · Ola 3 · Estructura
App shell
La estructura de una app: cabecera con la marca y el producto, navegación, acciones, barra lateral opcional y el contenido. Es la barra del demo llevada a una app: la marca del producto en blanco, sin la de Arche al lado, y la navegación con la barra de selección en el acento. En pantallas chicas la navegación pasa a un panel que se abre con un botón de menú. Para un producto editorial suma el ancho de página, la búsqueda, la barra de lectura, el pie y el menú a pantalla completa.
import { AppShell, AppShellLink } from '@archeblack/ui';
Ejemplos
Cada ejemplo es una página propia dentro de un marco, con el acento del producto de ejemplo: el AppShell ocupa toda la página y tiene sus propios landmarks. Se puede desplazar, navegar y abrir el menú.
Consola
Cabecera fija (sticky), navegación principal y acciones de solo ícono: búsqueda en un Popover, notificaciones y la cuenta en un Menu.
<script lang="ts">
import {
AppShell,
AppShellLink,
Avatar,
Badge,
Button,
Card,
Input,
Menu,
MenuItem,
MenuSeparator,
Popover
} from '@archeblack/ui';
import { IconBell, IconLogout, IconSearch, IconSettings } from '@archeblack/ui/icons';
const sections = [
{ id: 'resumen', label: 'Resumen' },
{ id: 'proyectos', label: 'Proyectos' },
{ id: 'equipo', label: 'Equipo' },
{ id: 'facturacion', label: 'Facturación' }
];
const projects = [
{ name: 'portal-clientes', status: 'Activo', updated: 'hace 6 minutos' },
{ name: 'api-pagos', status: 'Activo', updated: 'hace 2 horas' },
{ name: 'panel-interno', status: 'En pausa', updated: 'hace 3 días' },
{ name: 'sitio-marketing', status: 'Activo', updated: 'hace 1 semana' },
{ name: 'app-movil', status: 'Activo', updated: 'hace 2 semanas' },
{ name: 'servicio-correo', status: 'En pausa', updated: 'hace 3 semanas' },
{ name: 'datos-ventas', status: 'Activo', updated: 'hace 1 mes' },
{ name: 'docs-internas', status: 'En pausa', updated: 'hace 2 meses' }
];
// En un producto, la página actual sale de la URL (page.url.pathname en SvelteKit).
let current = $state('proyectos');
</script>
<!-- En el producto, data-product va en <html> (src/app.html) y la cabecera toma su acento. -->
<AppShell product="Consola" homeHref="#resumen" sticky>
{#snippet navigation()}
{#each sections as section (section.id)}
<AppShellLink
href="#{section.id}"
current={current === section.id}
onclick={() => (current = section.id)}
>
{section.label}
</AppShellLink>
{/each}
{/snippet}
{#snippet actions()}
<Popover aria-label="Buscar proyectos" align="end">
{#snippet trigger(props)}
<Button {...props} variant="ghost" icon={IconSearch} aria-label="Buscar proyectos" />
{/snippet}
<Input
type="search"
icon={IconSearch}
placeholder="Nombre del proyecto"
aria-label="Buscar proyectos"
/>
</Popover>
<Button variant="ghost" icon={IconBell} aria-label="Notificaciones" />
<Menu align="end">
{#snippet trigger(props)}
<!-- Un botón sin caja: el avatar es el objetivo y el foco lo rodea. -->
<button
{...props}
type="button"
aria-label="Cuenta de Jimmy Mora"
style:display="grid"
style:padding="0"
style:border="0"
style:background="none"
style:border-radius="var(--arche-radius-full)"
style:cursor="pointer"
>
<Avatar name="Jimmy Mora" alt="" />
</button>
{/snippet}
<MenuItem icon={IconSettings}>Preferencias</MenuItem>
<MenuSeparator />
<MenuItem icon={IconLogout}>Cerrar sesión</MenuItem>
</Menu>
{/snippet}
<div
style:display="grid"
style:gap="var(--arche-spacing-6)"
style:max-width="64rem"
style:padding="var(--arche-spacing-8) var(--arche-spacing-6)"
>
<div style:display="grid" style:gap="var(--arche-spacing-2)">
<h1 style:margin="0" style:font="var(--arche-typography-heading)">Proyectos</h1>
<p style:margin="0" style:color="var(--arche-color-text-muted)">
Ocho proyectos en la organización. La cabecera queda fija al desplazarse.
</p>
</div>
<div
style:display="grid"
style:grid-template-columns="repeat(auto-fill, minmax(min(16rem, 100%), 1fr))"
style:gap="var(--arche-spacing-4)"
>
{#each projects as project (project.name)}
<Card>
{#snippet header()}{project.name}{/snippet}
<div
style:display="flex"
style:align-items="center"
style:justify-content="space-between"
style:gap="var(--arche-spacing-3)"
>
<Badge variant={project.status === 'Activo' ? 'success' : 'neutral'} dot>
{project.status}
</Badge>
<span style:color="var(--arche-color-text-muted)">{project.updated}</span>
</div>
</Card>
{/each}
</div>
</div>
</AppShell> En un teléfono
La misma consola en un marco de 390 px: la navegación pasa al panel del botón de menú y las acciones siguen en la cabecera.
<script lang="ts">
import {
AppShell,
AppShellLink,
Avatar,
Badge,
Button,
Card,
Input,
Menu,
MenuItem,
MenuSeparator,
Popover
} from '@archeblack/ui';
import { IconBell, IconLogout, IconSearch, IconSettings } from '@archeblack/ui/icons';
const sections = [
{ id: 'resumen', label: 'Resumen' },
{ id: 'proyectos', label: 'Proyectos' },
{ id: 'equipo', label: 'Equipo' },
{ id: 'facturacion', label: 'Facturación' }
];
const projects = [
{ name: 'portal-clientes', status: 'Activo', updated: 'hace 6 minutos' },
{ name: 'api-pagos', status: 'Activo', updated: 'hace 2 horas' },
{ name: 'panel-interno', status: 'En pausa', updated: 'hace 3 días' },
{ name: 'sitio-marketing', status: 'Activo', updated: 'hace 1 semana' },
{ name: 'app-movil', status: 'Activo', updated: 'hace 2 semanas' },
{ name: 'servicio-correo', status: 'En pausa', updated: 'hace 3 semanas' },
{ name: 'datos-ventas', status: 'Activo', updated: 'hace 1 mes' },
{ name: 'docs-internas', status: 'En pausa', updated: 'hace 2 meses' }
];
// En un producto, la página actual sale de la URL (page.url.pathname en SvelteKit).
let current = $state('proyectos');
</script>
<!-- En el producto, data-product va en <html> (src/app.html) y la cabecera toma su acento. -->
<AppShell product="Consola" homeHref="#resumen" sticky>
{#snippet navigation()}
{#each sections as section (section.id)}
<AppShellLink
href="#{section.id}"
current={current === section.id}
onclick={() => (current = section.id)}
>
{section.label}
</AppShellLink>
{/each}
{/snippet}
{#snippet actions()}
<Popover aria-label="Buscar proyectos" align="end">
{#snippet trigger(props)}
<Button {...props} variant="ghost" icon={IconSearch} aria-label="Buscar proyectos" />
{/snippet}
<Input
type="search"
icon={IconSearch}
placeholder="Nombre del proyecto"
aria-label="Buscar proyectos"
/>
</Popover>
<Button variant="ghost" icon={IconBell} aria-label="Notificaciones" />
<Menu align="end">
{#snippet trigger(props)}
<!-- Un botón sin caja: el avatar es el objetivo y el foco lo rodea. -->
<button
{...props}
type="button"
aria-label="Cuenta de Jimmy Mora"
style:display="grid"
style:padding="0"
style:border="0"
style:background="none"
style:border-radius="var(--arche-radius-full)"
style:cursor="pointer"
>
<Avatar name="Jimmy Mora" alt="" />
</button>
{/snippet}
<MenuItem icon={IconSettings}>Preferencias</MenuItem>
<MenuSeparator />
<MenuItem icon={IconLogout}>Cerrar sesión</MenuItem>
</Menu>
{/snippet}
<div
style:display="grid"
style:gap="var(--arche-spacing-6)"
style:max-width="64rem"
style:padding="var(--arche-spacing-8) var(--arche-spacing-6)"
>
<div style:display="grid" style:gap="var(--arche-spacing-2)">
<h1 style:margin="0" style:font="var(--arche-typography-heading)">Proyectos</h1>
<p style:margin="0" style:color="var(--arche-color-text-muted)">
Ocho proyectos en la organización. La cabecera queda fija al desplazarse.
</p>
</div>
<div
style:display="grid"
style:grid-template-columns="repeat(auto-fill, minmax(min(16rem, 100%), 1fr))"
style:gap="var(--arche-spacing-4)"
>
{#each projects as project (project.name)}
<Card>
{#snippet header()}{project.name}{/snippet}
<div
style:display="flex"
style:align-items="center"
style:justify-content="space-between"
style:gap="var(--arche-spacing-3)"
>
<Badge variant={project.status === 'Activo' ? 'success' : 'neutral'} dot>
{project.status}
</Badge>
<span style:color="var(--arche-color-text-muted)">{project.updated}</span>
</div>
</Card>
{/each}
</div>
</div>
</AppShell> Editorial
La marca de Hermes (mark) en blanco, alineada con la columna de página (width="page"), con la búsqueda y su atajo, el pie (footer y footerNavigation) y la cabecera que se va: sin sticky, sube con la página y la barra de lectura del post (progress) queda sola arriba. ⌘K o Ctrl+K abre la búsqueda. Desplaza el marco.
<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> Editorial en un teléfono
Con menos de 48 rem quedan la lupa y el menú, a la derecha (menu="fullscreen"): una capa a pantalla completa con los links en la voz de los titulares a 40 px (typography.menu). Al abrirla, la marca y el botón no se mueven: la x cae donde estaba el menú.
<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> La marca de cada producto
Cada producto muestra solo su marca, en blanco: con mark, su sigilo y su nombre; con mark="arche" (o sin marca ni nombre), la de Arche; con product («Nombre»: un producto sin sigilo, Consola), solo su nombre en la voz del logotipo. El acento del producto queda en la barra de la pestaña actual.
<script lang="ts">
import { AppShell, AppShellLink, type MarkName } from '@archeblack/ui';
// La marca de la cabecera, en blanco y sin la de Arche al lado: con mark, el sigilo y el nombre
// del producto; con product (un producto sin sigilo), solo el nombre; sin ninguno, Arche.
let { mark, product }: { mark?: MarkName; product?: string } = $props();
</script>
<AppShell {mark} {product} homeHref="#inicio">
{#snippet navigation()}
<AppShellLink href="#inicio" current>Inicio</AppShellLink>
<AppShellLink href="#archivo">Archivo</AppShellLink>
{/snippet}
<div style:padding="var(--arche-spacing-8) var(--arche-spacing-6)">
<h1 style:margin="0" style:font="var(--arche-typography-heading)">Inicio</h1>
<p style:margin="var(--arche-spacing-2) 0 0" style:color="var(--arche-color-text-muted)">
La marca va en blanco; el acento del producto está en la barra de la pestaña actual.
</p>
</div>
</AppShell> Con barra lateral
sidebar suma la navegación de una sección a la izquierda, con íconos. En un teléfono va al panel del menú, debajo de la navegación principal.
<script lang="ts">
import { AppShell, AppShellLink, Button } from '@archeblack/ui';
import { IconBell, IconChartBar, IconHome, IconSettings, IconWorld } from '@archeblack/ui/icons';
const pages = [
{ id: 'general', label: 'General', icon: IconHome },
{ id: 'dominios', label: 'Dominios', icon: IconWorld },
{ id: 'metricas', label: 'Métricas', icon: IconChartBar },
{ id: 'ajustes', label: 'Ajustes', icon: IconSettings }
];
let current = $state('dominios');
</script>
<AppShell product="Consola" homeHref="#general">
{#snippet navigation()}
<AppShellLink href="#resumen">Resumen</AppShellLink>
<AppShellLink href="#proyectos" current>Proyectos</AppShellLink>
<AppShellLink href="#equipo">Equipo</AppShellLink>
{/snippet}
{#snippet actions()}
<Button variant="ghost" icon={IconBell} aria-label="Notificaciones" />
{/snippet}
{#snippet sidebar()}
<!-- La barra lateral es navegación: el producto pone su <nav> con nombre. -->
<nav aria-label="portal-clientes" style:display="grid" style:gap="var(--arche-spacing-1)">
{#each pages as page (page.id)}
<AppShellLink
href="#{page.id}"
icon={page.icon}
current={current === page.id}
onclick={() => (current = page.id)}
>
{page.label}
</AppShellLink>
{/each}
</nav>
{/snippet}
<div
style:display="grid"
style:gap="var(--arche-spacing-2)"
style:padding="var(--arche-spacing-8) var(--arche-spacing-6)"
>
<p
style:margin="0"
style:font="var(--arche-typography-label)"
style:letter-spacing="var(--arche-typography-label-letter-spacing)"
style:text-transform="uppercase"
style:color="var(--arche-color-text-muted)"
>
portal-clientes
</p>
<h1 style:margin="0" style:font="var(--arche-typography-heading)">
{pages.find((page) => page.id === current)?.label}
</h1>
<p style:margin="0" style:max-width="44rem" style:color="var(--arche-color-text-muted)">
La barra lateral lleva la navegación del proyecto. En pantallas de menos de 48 rem pasa al
panel del menú, debajo de la navegación principal.
</p>
</div>
</AppShell> En un producto
El AppShell va una sola vez, en el +layout.svelte raíz. El producto se activa con data-product en <html> y su CSS de producto.
El acento del producto
data-product va en <html> y no en la raíz del AppShell: el panel del menú, los menús y los popovers se abren en un portal
al final del <body>, y así también toman el acento.
Con él, la barra de selección, los links y el foco cambian de color; la marca de la cabecera
sigue en blanco.
<!doctype html><html lang="es" data-product="example"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> %sveltekit.head% </head> <body data-sveltekit-preload-data="hover"> <div style="display: contents">%sveltekit.body%</div> </body></html>El layout raíz
Importa los estilos y el CSS del producto, pone el link para saltar al contenido y marca la página actual desde la URL.
<script lang="ts"> import '@archeblack/ui/fonts.css'; import '@archeblack/ui/tokens.css'; import '@archeblack/ui/base.css'; import '@archeblack/ui/products/example.css'; import { page } from '$app/state'; import { resolve } from '$app/paths'; import { AppShell, AppShellLink } from '@archeblack/ui'; let { children } = $props(); const links = [ { href: resolve('/proyectos'), label: 'Proyectos' }, { href: resolve('/equipo'), label: 'Equipo' } ];</script><a class="arche-skip-link" href="#content">Ir al contenido</a><AppShell product="Example" homeHref={resolve('/')} sticky> {#snippet navigation()} {#each links as link (link.href)} <AppShellLink href={link.href} current={page.url.pathname.startsWith(link.href)}> {link.label} </AppShellLink> {/each} {/snippet} {@render children()}</AppShell>Cabecera fija o cabecera que se va
Las dos son parte del sistema. La elige el producto según lo que se hace en sus páginas, y de eso depende dónde va la barra de lectura.
Cabecera fija: una app
sticky. La navegación se usa todo el tiempo (la consola, un
panel de control), así que la cabecera queda arriba, sobre el contenido, y la página reserva
su alto con scroll-padding-top. Si una página es un texto
largo, la barra de lectura va en el borde inferior de la cabecera: ReadingProgress placement="header" en progress, con su pista sobre la línea.
<AppShell product="Consola" homeHref={resolve('/')} sticky> {#snippet navigation()}…{/snippet} <!-- Si una página de la app es un texto largo, la barra va en la cabecera. --> {#snippet progress()} <ReadingProgress placement="header" target={article} /> {/snippet} {@render children()}</AppShell>Cabecera que se va: una página de lectura
Sin sticky. En un producto editorial se lee más de lo que
se navega: la cabecera sube con la página y deja todo el alto al texto. En un post, la barra
de lectura queda sola arriba de la ventana (ReadingProgress con placement="viewport", el valor por defecto: fija, de
lado a lado, en z-index-sticky, con su pista de 2 px en border-subtle). Va en el mismo snippet progress, solo en la página del post, y mide su <article>. Es la configuración del patrón editorial (Hermes en /editorial).
<AppShell mark="hermes" homeHref={resolve('/')} width="page" menu="fullscreen"> {#snippet navigation()}…{/snippet} <!-- Sin sticky: la cabecera sube con la página y la barra queda sola arriba. --> {#snippet progress()} {#if isPost}<ReadingProgress target={reading.article} />{/if} {/snippet} {@render children()}</AppShell>Props
AppShell
| Prop | Descripción |
|---|---|
children Snippet | El contenido de la página. Va en un <main> sin relleno: cada página decide sus márgenes. |
product? string | Nombre de un producto sin sigilo: la cabecera lo muestra solo, en la voz del logotipo (Archivo 800 condensada al 75 %, 17 px, mayúsculas, tracking 0,1 em) y en blanco, sin la marca de Arche al lado ni un punto de color. Con mark se ignora. Sin mark ni product, la cabecera muestra la marca de Arche. Con menos de 48 rem de pantalla, un nombre largo se parte en lugar de cortarse con puntos suspensivos. |
mark? 'arche' | 'hermes' | 'iris' | 'hekate' | Marca del producto: la cabecera muestra su logotipo, el sigilo y el nombre (Logo layout="header" tone="white": sigilo de 20 px y palabra de 17 px a 8 px, en blanco), sin la marca de Arche al lado. Se ignora product. Con arche, la marca de Arche (su sigilo y ARCHE). El link a homeHref lo envuelve, con el anillo de foco de radio md. |
width? 'full' | 'page' Por defecto 'full' | full: la cabecera de una app, de borde a borde. page: la de un producto editorial, con la cabecera y el pie alineados con la columna de Container page (el margen fluido o lo que sobra de 80 rem a cada lado), también en un teléfono. En los dos la cabecera va en surface, con la línea border-subtle abajo. |
homeHref? string | Destino de la marca, casi siempre el inicio. El producto lo arma con resolve(). Sin homeHref, la marca no es un link. |
navigation? Snippet | Navegación principal: AppShellLink con current en el de la página actual. En la cabecera son pestañas con la barra de selección sobre la línea inferior; con menos de 48 rem de pantalla pasan al panel del menú. |
navigationLabel? string Por defecto 'Principal' | Nombre accesible del <nav> de la navegación principal. |
actions? Snippet | Acciones a la derecha de la cabecera: búsqueda, notificaciones, la cuenta. Quedan en la cabecera en todos los anchos, así que conviene que sean botones de solo ícono. |
searchHref? string | Destino de la búsqueda (la página /). Con searchHref o onSearch, la cabecera suma el botón de búsqueda antes de las acciones: la lupa, «Buscar» y el atajo, sin caja (Button ghost sm, 28 px de alto: la voz de los links de la cabecera) y, con menos de 48 rem, solo la lupa (Button ghost de solo ícono). Es un link: sin JavaScript lleva a la página de búsqueda. |
onSearch? (event: MouseEvent) => void | Se llama al elegir el botón de búsqueda. El link no navega (salvo con una tecla modificadora, que lo abre en otra pestaña) y el producto abre su capa, por ejemplo un Dialog fullscreen con bind:open. Al cerrarla, el foco vuelve al botón. |
searchLabel? string Por defecto 'Buscar' | Texto del botón de búsqueda y nombre accesible de la lupa. |
searchShortcut? string | Letra del atajo, por ejemplo K: el botón muestra «⌘K» en Mac o «Ctrl K» (en la letra de las etiquetas, text-subtle, sin caja) y lo declara en aria-keyshortcuts. Aparece al hidratar, cuando se sabe el sistema. El atajo lo escucha el producto con <svelte:window onkeydown>. |
sidebar? Snippet | Barra lateral de 15 rem a la izquierda del contenido, con una línea border-subtle y su propio desplazamiento. Si es navegación, el producto pone su <nav aria-label>. Con menos de 48 rem de pantalla pasa al panel del menú, debajo de la navegación principal. |
progress? Snippet | Lugar para la barra de lectura, como último hijo del <header>. Con la cabecera que se va (sin sticky): <ReadingProgress target={article} /, fija arriba de la ventana. Con la cabecera fija: <ReadingProgress placement="header" target={article} /; la cabecera es siempre su contenedor posicionado, así la barra y su pista tapan la línea inferior de lado a lado de la ventana, y conviene que en esa página ningún link lleve current (la barra del link actual ocupa la misma franja). Ver «Cabecera fija o cabecera que se va». |
footer? Snippet | Texto del pie, al inicio («© 2026 Hermes»), en la letra de las etiquetas y text-subtle. Con footer o footerNavigation el shell suma un <footer> (contentinfo) con una línea border-subtle arriba, 32 px de relleno y 64 px desde el contenido (48 con menos de 48 rem), el ritmo entre bloques grandes de una página editorial. El menú fullscreen lo repite abajo. |
footerNavigation? Snippet | Links del pie, al final: AppShellLink de 13 px en text-muted, que en hover pasan a text-strong con el subrayado de Arche. Si no entran junto al texto, bajan a su propia línea. |
footerNavigationLabel? string Por defecto 'Pie' | Nombre accesible del <nav> del pie. |
sticky? boolean Por defecto false | Cabecera fija: queda arriba al desplazarse, con z-index-sticky y el fondo opaco de surface. Suma scroll-padding-top a la página para que un ancla o un control enfocado no quede debajo de ella. Es la de una app. Sin sticky, la cabecera se va con la página: la de una página de lectura, con la barra de lectura arriba de la ventana. |
mainId? string Por defecto 'content' | id del <main>, el destino del link para saltar al contenido. |
menuLabel? string Por defecto 'Abrir la navegación' | Nombre accesible del botón de menú de las pantallas chicas (solo tiene un ícono). |
menuTitle? string Por defecto 'Navegación' | Título del panel del menú, que también lo nombra. En el menú fullscreen queda oculto a la vista y sigue nombrando a la capa. |
menu? 'sheet' | 'fullscreen' Por defecto 'sheet' | Menú de las pantallas chicas. sheet: un panel desde la izquierda, con el botón al inicio de la cabecera. fullscreen: una capa a pantalla completa (Dialog fullscreen) con los links en typography.menu (la voz de los titulares a 40 px) y el botón al final de la cabecera; su fila de arriba repite la cabecera, con la marca en el mismo lugar y la x donde estaba el botón. |
class? ClassValue | Clases del producto; se suman a arche-app-shell. |
...rest HTMLAttributes<HTMLDivElement> | Cualquier otro atributo va a la raíz. Un data-product puesto en la raíz se repite en el panel del menú, que se abre en un portal fuera de ella. |
AppShellLink
| Prop | Descripción |
|---|---|
href string | Destino del link. En SvelteKit, con resolve(). |
children Snippet | El texto del link. |
current? boolean | 'section' Por defecto false | Es la página actual: pone aria-current="page", el texto en text-strong y la barra de 2 px en color-selected, abajo en la cabecera y al inicio en la barra lateral y el panel. Con section, la página actual es una hija del destino del link (el archivo de un tema bajo «Temas»): se ve igual y pone aria-current="true", porque el link no lleva a esta página. |
icon? IconGlyph | Ícono decorativo de 16 px antes del texto. Conviene en la barra lateral; en la cabecera, el texto solo. |
class? ClassValue | Clases del producto; se suman a arche-app-shell__link. |
...rest HTMLAnchorAttributes | Cualquier otro atributo va al <a>. Su onclick se llama y, si el link está en el panel del menú, el panel se cierra. En el menú fullscreen el link va dentro de su fila (arche-app-shell__menu-item), la que lleva la línea de arriba. |
Accesibilidad
- Tiene los landmarks de una app: la cabecera es un
<header>(banner), la navegación principal un<nav>con nombre («Principal») y el contenido un<main>conid="content", el destino del link para saltar al contenido que pone el producto como primer elemento de la página. - Va una sola vez por página y fuera de otro
<main>: por eso la documentación lo muestra dentro de un iframe. - El link de la página actual lleva
aria-current="page", y el de su sección, en una página hija,aria-current="true"(current="section"): así el lector no anuncia como «página actual» un link que lleva a otra. La barra de selección y el texto entext-stronglo muestran, no solo el color. En colores forzados la barra pasa aHighlight. - La marca es un link nombrado con lo que se lee: el nombre del producto («Hermes», «Consola») o, sin producto, «Arche». La palabra se ve en mayúsculas por CSS, y Chrome pasa ese texto transformado al árbol de accesibilidad: el
aria-labelevita que el lector la deletree y contiene el texto visible. Conmark, el logotipo es una sola imagen con el mismo nombre y el sigilo va oculto por dentro. - Con menos de 48 rem de pantalla, la navegación y la barra lateral pasan a una
Sheetque entra desde la izquierda. El botón de menú es unButtonghostde solo ícono conaria-label(«Abrir la navegación»). LaSheetatrapa el foco y lo devuelve al botón al cerrar; elegir un link la cierra. Si la pantalla se agranda con el panel abierto, el panel se cierra y el foco, si estaba en el panel, pasa al link actual de la navegación de la cabecera (o al primero): el botón de menú ya no se ve y el foco no se pierde en la página. - Con
sticky, la cabecera no tapa el foco: la página sumascroll-padding-topdel alto de la cabecera, así un control enfocado con Tab o un ancla quedan a la vista (WCAG 2.4.11). Con la cabecera que se va no hay nada que reservar: solo la barra de lectura de 2 px queda arriba, yReadingProgresssuma 8 px descroll-padding-toppara que el anillo de foco no quede debajo de ella. - La navegación de la cabecera se desplaza a lo ancho si no entra, sin barra visible, y el anillo de foco de cada link no se recorta.
- En colores forzados la marca, el sigilo incluido, toma el color de link del sistema: no depende del blanco ni del acento.
- El menú
fullscreenes unDialogmodal: el botón llevaaria-haspopup="dialog",aria-expandedyaria-controls; al abrir, el foco va al primer link, Tab no sale de la capa, Escape y laxla cierran, el foco vuelve al botón y la página de atrás no se desplaza. El título «Navegación» queda para los lectores de pantalla y nombra la capa; la marca de la fila de arriba es decorativa (ya está en la cabecera). Los links van en filas dentro del<nav>, sin lista, como en la cabecera: el snippetnavigationes del producto y puede traer otra cosa además de links, que no sería un hijo válido de un<ul>. El actual llevaaria-current="page", el color de selección y una barra de 2 px al inicio de la fila, así no depende solo del color. Entra solo con opacidad: con movimiento reducido, sin transición. - El botón de búsqueda es un link a
searchHref: sin JavaScript lleva a la página de búsqueda, y con una tecla modificadora se abre en otra pestaña. Sin caja mide 28 px de alto (el objetivo mínimo es 24, WCAG 2.5.8) y lleva el anillo de foco debase.css; su nombre es el texto («Buscar»), con la lupa decorativa. El atajo va enaria-keyshortcuts; el<kbd>es decorativo (aria-hidden). La lupa de las pantallas chicas tiene nombre («Buscar»). - El pie es un
<footer>fuera de<main>: el landmarkcontentinfo. Sus links están en un<nav>con nombre («Pie»), miden 24 px de alto (WCAG 2.5.8) y en colores forzados se ven siempre subrayados.
Qué evitar
- Un AppShell por sección o dentro de otro
<main>. Uno solo en el+layout.svelteraíz del producto, que envuelve todas las páginas. - Más de cinco o seis links en la navegación principal. Las secciones de primer nivel arriba y el resto en la barra lateral de cada sección.
- Un campo de búsqueda ancho en las acciones: en un teléfono no entra junto a la marca. Un botón de solo ícono que abre la búsqueda en un
Popovero unDialog. - Más de tres acciones en la cabecera. Tres como mucho (búsqueda, avisos y la cuenta); el resto en el
Menude la cuenta. En una pantalla de 320 px, con más de tres la marca ya no entra entera junto a las acciones. - Poner la marca de Arche al lado de la del producto, o el sigilo en su color.
markcon la marca del producto: la cabecera la muestra sola y en blanco. El acento lo ponedata-producten<html>y el CSS del producto, en los links, la selección y el foco. - Un
<kbd>con estilos propios para mostrar el atajo de la búsqueda.searchShortcut: el botón lo muestra con la letra de las etiquetas y lo declara enaria-keyshortcuts. - Una cabecera propia para poner
ReadingProgressen la página de un post. El snippetprogressdel AppShell: arriba de la ventana con la cabecera que se va, o en la línea de la cabecera fija. - La cabecera fija en un producto de lectura, o la barra de lectura arriba de la ventana con la cabecera fija (las dos franjas se pisan). La cabecera que se va (sin
sticky) conReadingProgressenviewport; la fija, con la barra enheader, queda para las apps. - Marcar la página actual solo con el color del texto.
currenten elAppShellLink, que sumaaria-currenty la barra de selección.