Navigation
LinkSection
Enlace a una sección de la misma página, con scroll-spy nativo
Preview
Introducción
Instalación
Uso
Haz scroll dentro del documento: el navegador marca solo la sección visible. Lo enciende scroll-target-group: auto en la lista, hoy solo en Chromium; active la marca a mano.
size
children
active
Code
import css from "./LinkSection.module.css";
type Size = "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface LinkSectionProps {
href: `#${string}`;
size: Size | ResponsiveSize;
active?: boolean;
children: React.ReactNode;
}
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 LinkSection({
href,
size,
active,
children,
}: LinkSectionProps) {
return (
<a
href={href}
className={`
${css.link}
${getSizeClasses(size)}
${active ? css.active : ""}
`}
aria-current={active ? "location" : undefined}
>
{children}
</a>
);
}
.link {
display: flex;
align-items: center;
/* 4, not 8: a 36px row cannot hold 8 + 8 around a 21px line; the min-height centres it. */
padding: var(--space-4) var(--space-12);
font-weight: var(--weight-500);
color: var(--color-text-secondary);
text-decoration: none;
border-radius: var(--radius-4);
transition:
background 0.15s ease,
color 0.15s ease;
-webkit-tap-highlight-color: transparent;
&.sm {
font-size: var(--text-14);
min-height: var(--size-36);
}
&.md {
font-size: var(--text-14);
min-height: var(--size-40);
}
&.lg {
font-size: var(--text-16);
min-height: var(--size-44);
}
&.sm_base {
font-size: var(--text-14);
min-height: var(--size-36);
}
&.md_base {
font-size: var(--text-14);
min-height: var(--size-40);
}
&.lg_base {
font-size: var(--text-16);
min-height: var(--size-44);
}
@media (min-width: 480px) {
&.sm_mobile {
font-size: var(--text-14);
min-height: var(--size-36);
}
&.md_mobile {
font-size: var(--text-14);
min-height: var(--size-40);
}
&.lg_mobile {
font-size: var(--text-16);
min-height: var(--size-44);
}
}
@media (min-width: 768px) {
&.sm_tablet {
font-size: var(--text-14);
min-height: var(--size-36);
}
&.md_tablet {
font-size: var(--text-14);
min-height: var(--size-40);
}
&.lg_tablet {
font-size: var(--text-16);
min-height: var(--size-44);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
font-size: var(--text-14);
min-height: var(--size-36);
}
&.md_desktop {
font-size: var(--text-14);
min-height: var(--size-40);
}
&.lg_desktop {
font-size: var(--text-16);
min-height: var(--size-44);
}
}
@media (min-width: 1440px) {
&.sm_wide {
font-size: var(--text-14);
min-height: var(--size-36);
}
&.md_wide {
font-size: var(--text-14);
min-height: var(--size-40);
}
&.lg_wide {
font-size: var(--text-16);
min-height: var(--size-44);
}
}
/* Inset: index rows sit close together, so a ring drawn outside would run into the neighbour. */
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
}
/* :is() forgives the selector it cannot parse, so a browser
without scroll-spy still matches .active. Colour and fill
only: a weight swap shimmers while you scroll. */
&:is(.active, :target-current) {
background: color-mix(in srgb, var(--color-text) 12%, transparent);
color: var(--color-text);
}
&:active {
background: color-mix(in srgb, var(--color-text) 16%, transparent);
color: var(--color-text);
}
}
<script setup lang="ts">
import { computed } from "vue";
type Size = "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface LinkSectionProps {
href: `#${string}`;
size: Size | ResponsiveSize;
active?: boolean;
}
const { size } = defineProps<LinkSectionProps>();
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>
<a
:href="href"
class="link"
:class="[sizeClasses, { active }]"
:aria-current="active ? 'location' : undefined"
>
<slot></slot>
</a>
</template>
<style scoped>
.link {
display: flex;
align-items: center;
/* 4, not 8: a 36px row cannot hold 8 + 8 around a 21px line; the min-height centres it. */
padding: var(--space-4) var(--space-12);
font-weight: var(--weight-500);
color: var(--color-text-secondary);
text-decoration: none;
border-radius: var(--radius-4);
transition:
background 0.15s ease,
color 0.15s ease;
-webkit-tap-highlight-color: transparent;
&.sm {
font-size: var(--text-14);
min-height: var(--size-36);
}
&.md {
font-size: var(--text-14);
min-height: var(--size-40);
}
&.lg {
font-size: var(--text-16);
min-height: var(--size-44);
}
&.sm_base {
font-size: var(--text-14);
min-height: var(--size-36);
}
&.md_base {
font-size: var(--text-14);
min-height: var(--size-40);
}
&.lg_base {
font-size: var(--text-16);
min-height: var(--size-44);
}
@media (min-width: 480px) {
&.sm_mobile {
font-size: var(--text-14);
min-height: var(--size-36);
}
&.md_mobile {
font-size: var(--text-14);
min-height: var(--size-40);
}
&.lg_mobile {
font-size: var(--text-16);
min-height: var(--size-44);
}
}
@media (min-width: 768px) {
&.sm_tablet {
font-size: var(--text-14);
min-height: var(--size-36);
}
&.md_tablet {
font-size: var(--text-14);
min-height: var(--size-40);
}
&.lg_tablet {
font-size: var(--text-16);
min-height: var(--size-44);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
font-size: var(--text-14);
min-height: var(--size-36);
}
&.md_desktop {
font-size: var(--text-14);
min-height: var(--size-40);
}
&.lg_desktop {
font-size: var(--text-16);
min-height: var(--size-44);
}
}
@media (min-width: 1440px) {
&.sm_wide {
font-size: var(--text-14);
min-height: var(--size-36);
}
&.md_wide {
font-size: var(--text-14);
min-height: var(--size-40);
}
&.lg_wide {
font-size: var(--text-16);
min-height: var(--size-44);
}
}
/* Inset: index rows sit close together, so a ring drawn outside would run into the neighbour. */
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
@media (hover: hover) {
&:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
}
/* :is() forgives the selector it cannot parse, so a browser
without scroll-spy still matches .active. Colour and fill
only: a weight swap shimmers while you scroll. */
&:is(.active, :target-current) {
background: color-mix(in srgb, var(--color-text) 12%, transparent);
color: var(--color-text);
}
&:active {
background: color-mix(in srgb, var(--color-text) 16%, transparent);
color: var(--color-text);
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| href | `#${string}` | required | Ancla de una sección de la misma página. El tipo rechaza rutas como "/about" al compilar. |
| size | "sm" | "md" | "lg" | ResponsiveSize | required | Tamaño del enlace (altura y tipografía). Fijo o responsive. |
| active | boolean | — | Marca el enlace como sección actual a mano (aria-current="location", fondo tintado). Con scroll-target-group: auto en la lista (hoy solo en Chromium), el navegador pinta solo la sección visible, sin aria-current. |
| children | React.ReactNode | required | Contenido del enlace. |