Navigation
Breadcrumb
Ruta de navegación jerárquica con separadores
Preview
size
items
Code
import css from "./Breadcrumb.module.css";
import { CaretRightIcon } from "@phosphor-icons/react/dist/csr/CaretRight";
type Size = "sm" | "md";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
size: Size | ResponsiveSize;
"aria-label": string;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Breadcrumb({
items,
size,
"aria-label": ariaLabel,
}: BreadcrumbProps) {
return (
<nav
className={`
${css.breadcrumb}
${getSizeClasses(size)}
`}
aria-label={ariaLabel}
>
<ol className={css.breadcrumb_list} role="list">
{items.map((item, index) => {
const last = index === items.length - 1;
const Tag = item.href ? "a" : "span";
return (
<li key={item.label} className={css.breadcrumb_item}>
{last ? (
<span className={css.breadcrumb_current} aria-current="page">
{item.label}
</span>
) : (
<Tag className={css.breadcrumb_link} href={item.href}>
{item.label}
</Tag>
)}
{!last && (
<CaretRightIcon
weight="bold"
aria-hidden="true"
className={css.breadcrumb_separator}
/>
)}
</li>
);
})}
</ol>
</nav>
);
}
.breadcrumb {
font-family: var(--font-body);
&.sm {
font-size: var(--text-14);
}
&.md {
font-size: var(--text-16);
}
&.sm_base {
font-size: var(--text-14);
}
&.md_base {
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.sm_mobile {
font-size: var(--text-14);
}
&.md_mobile {
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.sm_tablet {
font-size: var(--text-14);
}
&.md_tablet {
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
font-size: var(--text-14);
}
&.md_desktop {
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.sm_wide {
font-size: var(--text-14);
}
&.md_wide {
font-size: var(--text-16);
}
}
.breadcrumb_list {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
padding: 0;
list-style: none;
.breadcrumb_item {
display: flex;
align-items: center;
gap: var(--space-8);
.breadcrumb_link {
display: flex;
align-items: center;
min-height: 24px;
color: var(--color-text-secondary);
text-decoration: none;
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
transition: color 150ms ease;
/* A level without href is a span with this class: only a real link answers the pointer. */
@media (hover: hover) {
&:any-link:hover {
color: var(--color-accent);
}
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
.breadcrumb_separator {
/* 1em: the caret matches the text size and scales with font-size
(overrides Phosphor's SVG attributes) */
width: 1em;
height: 1em;
/* An svg flex item has no content minimum, so width pressure crushes
the carets to 0 before the labels budge. */
flex-shrink: 0;
color: var(--color-text-tertiary);
}
.breadcrumb_current {
color: var(--color-text);
font-weight: var(--weight-500);
}
}
}
}
<script setup lang="ts">
import { computed } from "vue";
import { PhCaretRight } from "@phosphor-icons/vue";
type Size = "sm" | "md";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
size: Size | ResponsiveSize;
ariaLabel: string;
}
const { size, ariaLabel } = defineProps<BreadcrumbProps>();
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
</script>
<template>
<nav class="breadcrumb" :class="sizeClasses" :aria-label="ariaLabel">
<ol class="breadcrumb_list" role="list">
<li
v-for="(item, index) in items"
:key="item.label"
class="breadcrumb_item"
>
<span
v-if="index === items.length - 1"
class="breadcrumb_current"
aria-current="page"
>
{{ item.label }}
</span>
<a v-else-if="item.href" class="breadcrumb_link" :href="item.href">
{{ item.label }}
</a>
<span v-else class="breadcrumb_link">{{ item.label }}</span>
<PhCaretRight
v-if="index !== items.length - 1"
weight="bold"
aria-hidden="true"
class="breadcrumb_separator"
/>
</li>
</ol>
</nav>
</template>
<style scoped>
.breadcrumb {
font-family: var(--font-body);
&.sm {
font-size: var(--text-14);
}
&.md {
font-size: var(--text-16);
}
&.sm_base {
font-size: var(--text-14);
}
&.md_base {
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.sm_mobile {
font-size: var(--text-14);
}
&.md_mobile {
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.sm_tablet {
font-size: var(--text-14);
}
&.md_tablet {
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
font-size: var(--text-14);
}
&.md_desktop {
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.sm_wide {
font-size: var(--text-14);
}
&.md_wide {
font-size: var(--text-16);
}
}
.breadcrumb_list {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
padding: 0;
list-style: none;
.breadcrumb_item {
display: flex;
align-items: center;
gap: var(--space-8);
.breadcrumb_link {
display: flex;
align-items: center;
min-height: 24px;
color: var(--color-text-secondary);
text-decoration: none;
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
transition: color 150ms ease;
/* A level without href is a span with this class: only a real link answers the pointer. */
@media (hover: hover) {
&:any-link:hover {
color: var(--color-accent);
}
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
.breadcrumb_separator {
/* 1em: the caret matches the text size and scales with font-size
(overrides Phosphor's SVG attributes) */
width: 1em;
height: 1em;
/* An svg flex item has no content minimum, so width pressure crushes
the carets to 0 before the labels budge. */
flex-shrink: 0;
color: var(--color-text-tertiary);
}
.breadcrumb_current {
color: var(--color-text);
font-weight: var(--weight-500);
}
}
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| items | BreadcrumbItem[] | required | Ruta ordenada: { label, href? } por nivel. El último es la página actual (aria-current); un nivel sin href se pinta como texto plano. |
| size | "sm" | "md" | ResponsiveSize | required | Tamaño del texto y el separador. Fijo o responsive. |
| aria-label | string | required | Nombre del <nav> para lectores de pantalla; distingue esta navegación de las demás de la página. |