Плавающее меню
FloatingMenuDemo — анимированная навигационная панель. Нажмите кнопку «Menu» слева, чтобы раскрыть сетку групп ссылок с плавной анимацией на GSAP: контейнер расширяется, затемнение появляется, а строки ссылок выезжают по очереди.
Интерактивное превью
Нажмите «Menu», чтобы открыть панель. Нажмите ещё раз, нажмите на затемнённую область или клавишу Escape, чтобы закрыть.
Продукты
Ресурсы
<script lang="ts">
import { untrack } from "svelte";
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
import { onMount } from "svelte";
import type { ClassValue } from "clsx";
import type { Snippet } from "svelte";
import { ensureMotionCoreEase, registerPluginOnce } from "../../helpers/gsap";
import { cn } from "../../utils/cn";
import { portal } from "../../utils/use-portal";
type MenuVariant = "default" | "muted";
type MenuLink = {
label: string;
href: string;
}
type MenuButton = {
label: string;
href: string;
}
type MenuGroup = {
title: string;
variant?: MenuVariant;
links: MenuLink[];
}
type FloatingMenuClasses = {
root?: ClassValue;
overlay?: ClassValue;
header?: ClassValue;
toggleButton?: ClassValue;
toggleLine?: ClassValue;
logo?: ClassValue;
actions?: ClassValue;
primaryButton?: ClassValue;
secondaryButton?: ClassValue;
menuWrapper?: ClassValue;
grid?: ClassValue;
group?: ClassValue;
groupMuted?: ClassValue;
groupTitle?: ClassValue;
link?: ClassValue;
linkText?: ClassValue;
linkUnderline?: ClassValue;
divider?: ClassValue;
}
type Props = {
menuGroups: MenuGroup[];
logo?: Snippet;
primaryButton?: MenuButton;
secondaryButton?: MenuButton;
class?: string;
classes?: FloatingMenuClasses;
portalTarget?: HTMLElement | string;
}
let {
menuGroups,
logo,
primaryButton,
secondaryButton,
class: className,
classes,
portalTarget = "body",
}: Props = $props();
const positionClass = $derived(portalTarget !== "body" ? "absolute" : "fixed");
let isOpen = $state(false);
let timeline: gsap.core.Timeline | null = null;
let containerRef: HTMLElement;
let menuWrapperRef: HTMLElement;
let line1Ref: HTMLElement;
let line2Ref: HTMLElement;
let overlayRef: HTMLElement;
const attachContainerRef = (node: HTMLElement) => {
containerRef = node;
};
const attachMenuWrapperRef = (node: HTMLElement) => {
menuWrapperRef = node;
};
const attachLine1Ref = (node: HTMLElement) => {
line1Ref = node;
};
const attachLine2Ref = (node: HTMLElement) => {
line2Ref = node;
};
const attachOverlayRef = (node: HTMLElement) => {
overlayRef = node;
};
function toggle() {
if (!timeline) return;
isOpen = !isOpen;
if (isOpen) {
timeline.play();
} else {
timeline.reverse();
}
}
onMount(() => {
registerPluginOnce(SplitText);
ensureMotionCoreEase();
});
$effect(() => {
if (!menuGroups.length) return;
let cancelled = false;
let splits: SplitText[] = [];
let ctx: gsap.Context | null = null;
const init = async () => {
await document.fonts.ready;
if (cancelled) return;
const width = window.innerWidth;
const isMobile = width < 768;
const isTablet = width >= 768 && width < 1024;
let maxWidthOpen = "75%";
let maxWidthInitial = "50%";
if (isMobile) {
maxWidthOpen = "100%";
maxWidthInitial = "95%";
} else if (isTablet) {
maxWidthOpen = "85%";
maxWidthInitial = "70%";
}
ctx?.revert();
ctx = gsap.context(() => {
gsap.set(overlayRef, { autoAlpha: 0 });
gsap.set(containerRef, { maxWidth: maxWidthInitial });
gsap.set(menuWrapperRef, { height: 0, autoAlpha: 0 });
const linkElements = gsap.utils.toArray<HTMLElement>(
`[data-slot="link-text"]`,
menuWrapperRef,
);
splits = linkElements.map((el) =>
SplitText.create(el, { type: "lines", mask: "lines" }),
);
const allLines = splits.flatMap((s) => s.lines);
timeline = gsap.timeline({
paused: true,
defaults: { ease: "motion-core-ease", duration: 0.5 },
});
timeline
.to(
containerRef,
{
maxWidth: maxWidthOpen,
...(isMobile
? {
top: 0,
paddingTop: "0.5rem",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
}
: {}),
},
0,
)
.to(overlayRef, { autoAlpha: 1 }, 0)
.to(menuWrapperRef, { height: "auto", autoAlpha: 1 }, 0.2)
.to([line1Ref, line2Ref], { y: 0, duration: 0.4 }, 0.2)
.to(line1Ref, { rotation: 45, duration: 0.4 }, 0.2)
.to(line2Ref, { rotation: -45, duration: 0.4 }, 0.2);
if (allLines.length) {
timeline.from(
allLines,
{
yPercent: 100,
autoAlpha: 0,
stagger: 0.02,
},
0.3,
);
}
}, containerRef);
if (untrack(() => isOpen)) {
timeline?.progress(1);
}
};
void init();
return () => {
cancelled = true;
ctx?.revert();
ctx = null;
timeline = null;
splits.forEach((s) => { s.revert(); });
};
});
</script>
<div
use:portal={portalTarget}
{@attach attachOverlayRef}
data-slot="overlay"
class={cn(
"pointer-events-none inset-0 z-40 bg-background-inset/80 opacity-0 data-[open=true]:pointer-events-auto",
positionClass,
classes?.overlay,
)}
data-open={isOpen}
onclick={toggle}
onkeydown={(e) => {
if (e.key === "Escape" && isOpen) {
e.preventDefault();
toggle();
}
}}
role="button"
tabindex="-1"
aria-label="Close menu"
></div>
<div
use:portal={portalTarget}
{@attach attachContainerRef}
data-slot="root"
class={cn(
"top-2 left-1/2 z-50 w-full max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-md border border-border bg-background text-foreground shadow-md md:top-4 md:max-w-[70vw] lg:max-w-[50vw]",
positionClass,
className,
classes?.root,
)}
>
<div
data-slot="header"
class={cn(
"relative z-20 flex w-full items-center justify-between p-1",
classes?.header,
)}
>
<button
onclick={toggle}
data-slot="toggle-button"
class={cn(
"group relative flex h-10 items-center justify-center rounded-sm pr-2 transition-[background-color] duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] hover:bg-accent/10",
classes?.toggleButton,
)}
aria-label="Toggle menu"
>
<div class="relative flex h-10 w-10 items-center justify-center">
<span
{@attach attachLine1Ref}
data-slot="toggle-line"
class={cn(
"absolute h-px w-6 bg-foreground transition-[background-color] duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] group-hover:bg-accent",
classes?.toggleLine,
)}
style="transform: translateY(4px)"
></span>
<span
{@attach attachLine2Ref}
data-slot="toggle-line"
class={cn(
"absolute h-px w-6 bg-foreground transition-[background-color] duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] group-hover:bg-accent",
classes?.toggleLine,
)}
style="transform: translateY(-4px)"
></span>
</div>
<span
class="ml-1 text-sm font-medium text-foreground transition-[color] duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] group-hover:text-accent"
>
Menu
</span>
</button>
<div
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform-gpu"
style="backface-visibility: hidden;"
>
{#if logo}
<div
data-slot="logo"
class={cn("flex items-center gap-3", classes?.logo)}
>
{@render logo()}
</div>
{/if}
</div>
<div
data-slot="actions"
class={cn("flex items-center gap-1", classes?.actions)}
>
{#if secondaryButton}
<a
href={secondaryButton.href}
data-slot="secondary-button"
class={cn(
"hidden h-10 items-center justify-center rounded-sm px-4 text-sm font-medium text-foreground transition-[background-color,color] duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] hover:bg-background-muted hover:text-foreground md:flex",
classes?.secondaryButton,
)}
>
{secondaryButton.label}
</a>
{/if}
{#if primaryButton}
<a
href={primaryButton.href}
data-slot="primary-button"
class={cn(
"flex h-10 items-center justify-center rounded-sm bg-accent/10 px-4 text-sm font-medium text-accent transition-[background-color] duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] hover:bg-accent/20",
classes?.primaryButton,
)}
>
{primaryButton.label}
</a>
{/if}
</div>
</div>
<div
{@attach attachMenuWrapperRef}
data-slot="menu-wrapper"
class={cn(
"h-0 w-full overflow-hidden border-t border-border opacity-0",
classes?.menuWrapper,
)}
>
<div
data-slot="grid"
class={cn(
"grid max-h-[65vh] grid-cols-1 gap-4 overflow-y-auto overscroll-contain p-4 md:max-h-none md:grid-cols-3 md:overflow-visible",
classes?.grid,
)}
>
{#each menuGroups as group (group.title)}
<div
data-slot="group"
class={cn(
"flex flex-col gap-4 rounded-sm p-4 transition-colors ease-[cubic-bezier(0.625,0.05,0,1)]",
group.variant === "muted"
? "bg-background-muted"
: "bg-transparent",
classes?.group,
group.variant === "muted" && classes?.groupMuted,
)}
>
<h3
data-slot="group-title"
class={cn(
"font-mono text-xs font-medium tracking-wider text-foreground-muted/50 uppercase",
classes?.groupTitle,
)}
>
{group.title}
</h3>
<div class="mt-4 flex flex-col gap-4">
{#each group.links as link, i (link.href + link.label)}
<a
href={link.href}
data-slot="link"
class={cn(
"group/link relative block w-fit text-2xl font-normal text-foreground-muted transition-colors duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] hover:text-foreground",
classes?.link,
)}
>
<span class="relative z-10 block leading-tight">
<span
data-slot="link-text"
class={cn(
"menu-link-text block whitespace-nowrap",
classes?.linkText,
)}
>
{link.label}
</span>
</span>
<span
data-slot="link-underline"
class={cn(
"absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-foreground transition-transform duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] group-hover/link:origin-left group-hover/link:scale-x-100",
classes?.linkUnderline,
)}
></span>
</a>
{#if i < group.links.length - 1}
<hr
data-slot="divider"
class={cn("border-border", classes?.divider)}
/>
{/if}
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
<script lang="ts">
import { untrack } from "svelte";
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
import { onMount } from "svelte";
import type { ClassValue } from "clsx";
import type { Snippet } from "svelte";
import { ensureMotionCoreEase, registerPluginOnce } from "../../helpers/gsap";
import { cn } from "../../utils/cn";
import { portal } from "../../utils/use-portal";
type MenuVariant = "default" | "muted";
type MenuLink = {
label: string;
href: string;
}
type MenuButton = {
label: string;
href: string;
}
type MenuGroup = {
title: string;
variant?: MenuVariant;
links: MenuLink[];
}
type FloatingMenuClasses = {
root?: ClassValue;
overlay?: ClassValue;
header?: ClassValue;
toggleButton?: ClassValue;
toggleLine?: ClassValue;
logo?: ClassValue;
actions?: ClassValue;
primaryButton?: ClassValue;
secondaryButton?: ClassValue;
menuWrapper?: ClassValue;
grid?: ClassValue;
group?: ClassValue;
groupMuted?: ClassValue;
groupTitle?: ClassValue;
link?: ClassValue;
linkText?: ClassValue;
linkUnderline?: ClassValue;
divider?: ClassValue;
}
type Props = {
menuGroups: MenuGroup[];
logo?: Snippet;
primaryButton?: MenuButton;
secondaryButton?: MenuButton;
class?: string;
classes?: FloatingMenuClasses;
portalTarget?: HTMLElement | string;
}
let {
menuGroups,
logo,
primaryButton,
secondaryButton,
class: className,
classes,
portalTarget = "body",
}: Props = $props();
const positionClass = $derived(portalTarget !== "body" ? "absolute" : "fixed");
let isOpen = $state(false);
let timeline: gsap.core.Timeline | null = null;
let containerRef: HTMLElement;
let menuWrapperRef: HTMLElement;
let line1Ref: HTMLElement;
let line2Ref: HTMLElement;
let overlayRef: HTMLElement;
const attachContainerRef = (node: HTMLElement) => {
containerRef = node;
};
const attachMenuWrapperRef = (node: HTMLElement) => {
menuWrapperRef = node;
};
const attachLine1Ref = (node: HTMLElement) => {
line1Ref = node;
};
const attachLine2Ref = (node: HTMLElement) => {
line2Ref = node;
};
const attachOverlayRef = (node: HTMLElement) => {
overlayRef = node;
};
function toggle() {
if (!timeline) return;
isOpen = !isOpen;
if (isOpen) {
timeline.play();
} else {
timeline.reverse();
}
}
onMount(() => {
registerPluginOnce(SplitText);
ensureMotionCoreEase();
});
$effect(() => {
if (!menuGroups.length) return;
let cancelled = false;
let splits: SplitText[] = [];
let ctx: gsap.Context | null = null;
const init = async () => {
await document.fonts.ready;
if (cancelled) return;
const width = window.innerWidth;
const isMobile = width < 768;
const isTablet = width >= 768 && width < 1024;
let maxWidthOpen = "75%";
let maxWidthInitial = "50%";
if (isMobile) {
maxWidthOpen = "100%";
maxWidthInitial = "95%";
} else if (isTablet) {
maxWidthOpen = "85%";
maxWidthInitial = "70%";
}
ctx?.revert();
ctx = gsap.context(() => {
gsap.set(overlayRef, { autoAlpha: 0 });
gsap.set(containerRef, { maxWidth: maxWidthInitial });
gsap.set(menuWrapperRef, { height: 0, autoAlpha: 0 });
const linkElements = gsap.utils.toArray<HTMLElement>(
`[data-slot="link-text"]`,
menuWrapperRef,
);
splits = linkElements.map((el) =>
SplitText.create(el, { type: "lines", mask: "lines" }),
);
const allLines = splits.flatMap((s) => s.lines);
timeline = gsap.timeline({
paused: true,
defaults: { ease: "motion-core-ease", duration: 0.5 },
});
timeline
.to(
containerRef,
{
maxWidth: maxWidthOpen,
...(isMobile
? {
top: 0,
paddingTop: "0.5rem",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
}
: {}),
},
0,
)
.to(overlayRef, { autoAlpha: 1 }, 0)
.to(menuWrapperRef, { height: "auto", autoAlpha: 1 }, 0.2)
.to([line1Ref, line2Ref], { y: 0, duration: 0.4 }, 0.2)
.to(line1Ref, { rotation: 45, duration: 0.4 }, 0.2)
.to(line2Ref, { rotation: -45, duration: 0.4 }, 0.2);
if (allLines.length) {
timeline.from(
allLines,
{
yPercent: 100,
autoAlpha: 0,
stagger: 0.02,
},
0.3,
);
}
}, containerRef);
if (untrack(() => isOpen)) {
timeline?.progress(1);
}
};
void init();
return () => {
cancelled = true;
ctx?.revert();
ctx = null;
timeline = null;
splits.forEach((s) => { s.revert(); });
};
});
</script>
<div
use:portal={portalTarget}
{@attach attachOverlayRef}
data-slot="overlay"
class={cn(
"pointer-events-none inset-0 z-40 bg-background-inset/80 opacity-0 data-[open=true]:pointer-events-auto",
positionClass,
classes?.overlay,
)}
data-open={isOpen}
onclick={toggle}
onkeydown={(e) => {
if (e.key === "Escape" && isOpen) {
e.preventDefault();
toggle();
}
}}
role="button"
tabindex="-1"
aria-label="Close menu"
></div>
<div
use:portal={portalTarget}
{@attach attachContainerRef}
data-slot="root"
class={cn(
"top-2 left-1/2 z-50 w-full max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-md border border-border bg-background text-foreground shadow-md md:top-4 md:max-w-[70vw] lg:max-w-[50vw]",
positionClass,
className,
classes?.root,
)}
>
<div
data-slot="header"
class={cn(
"relative z-20 flex w-full items-center justify-between p-1",
classes?.header,
)}
>
<button
onclick={toggle}
data-slot="toggle-button"
class={cn(
"group relative flex h-10 items-center justify-center rounded-sm pr-2 transition-[background-color] duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] hover:bg-accent/10",
classes?.toggleButton,
)}
aria-label="Toggle menu"
>
<div class="relative flex h-10 w-10 items-center justify-center">
<span
{@attach attachLine1Ref}
data-slot="toggle-line"
class={cn(
"absolute h-px w-6 bg-foreground transition-[background-color] duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] group-hover:bg-accent",
classes?.toggleLine,
)}
style="transform: translateY(4px)"
></span>
<span
{@attach attachLine2Ref}
data-slot="toggle-line"
class={cn(
"absolute h-px w-6 bg-foreground transition-[background-color] duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] group-hover:bg-accent",
classes?.toggleLine,
)}
style="transform: translateY(-4px)"
></span>
</div>
<span
class="ml-1 text-sm font-medium text-foreground transition-[color] duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] group-hover:text-accent"
>
Menu
</span>
</button>
<div
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform-gpu"
style="backface-visibility: hidden;"
>
{#if logo}
<div
data-slot="logo"
class={cn("flex items-center gap-3", classes?.logo)}
>
{@render logo()}
</div>
{/if}
</div>
<div
data-slot="actions"
class={cn("flex items-center gap-1", classes?.actions)}
>
{#if secondaryButton}
<a
href={secondaryButton.href}
data-slot="secondary-button"
class={cn(
"hidden h-10 items-center justify-center rounded-sm px-4 text-sm font-medium text-foreground transition-[background-color,color] duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] hover:bg-background-muted hover:text-foreground md:flex",
classes?.secondaryButton,
)}
>
{secondaryButton.label}
</a>
{/if}
{#if primaryButton}
<a
href={primaryButton.href}
data-slot="primary-button"
class={cn(
"flex h-10 items-center justify-center rounded-sm bg-accent/10 px-4 text-sm font-medium text-accent transition-[background-color] duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] hover:bg-accent/20",
classes?.primaryButton,
)}
>
{primaryButton.label}
</a>
{/if}
</div>
</div>
<div
{@attach attachMenuWrapperRef}
data-slot="menu-wrapper"
class={cn(
"h-0 w-full overflow-hidden border-t border-border opacity-0",
classes?.menuWrapper,
)}
>
<div
data-slot="grid"
class={cn(
"grid max-h-[65vh] grid-cols-1 gap-4 overflow-y-auto overscroll-contain p-4 md:max-h-none md:grid-cols-3 md:overflow-visible",
classes?.grid,
)}
>
{#each menuGroups as group (group.title)}
<div
data-slot="group"
class={cn(
"flex flex-col gap-4 rounded-sm p-4 transition-colors ease-[cubic-bezier(0.625,0.05,0,1)]",
group.variant === "muted"
? "bg-background-muted"
: "bg-transparent",
classes?.group,
group.variant === "muted" && classes?.groupMuted,
)}
>
<h3
data-slot="group-title"
class={cn(
"font-mono text-xs font-medium tracking-wider text-foreground-muted/50 uppercase",
classes?.groupTitle,
)}
>
{group.title}
</h3>
<div class="mt-4 flex flex-col gap-4">
{#each group.links as link, i (link.href + link.label)}
<a
href={link.href}
data-slot="link"
class={cn(
"group/link relative block w-fit text-2xl font-normal text-foreground-muted transition-colors duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] hover:text-foreground",
classes?.link,
)}
>
<span class="relative z-10 block leading-tight">
<span
data-slot="link-text"
class={cn(
"menu-link-text block whitespace-nowrap",
classes?.linkText,
)}
>
{link.label}
</span>
</span>
<span
data-slot="link-underline"
class={cn(
"absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-foreground transition-transform duration-400 ease-[cubic-bezier(0.625,0.05,0,1)] group-hover/link:origin-left group-hover/link:scale-x-100",
classes?.linkUnderline,
)}
></span>
</a>
{#if i < group.links.length - 1}
<hr
data-slot="divider"
class={cn("border-border", classes?.divider)}
/>
{/if}
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
Как устроено
menuGroupsзадаёт группы ссылок. Каждая группа содержитtitle, массивlinksи опциональныйvariant: "muted"для группы с фоном.logo— сниппет брендинга по центру шапки.primaryButtonиsecondaryButtonдобавляют кнопки-ссылки в правую часть шапки.classesпозволяет переопределить стили отдельных областей без изменения компонента.portalTargetуказывает, куда переносится меню через портал. По умолчанию —"body"; для встраивания в ограниченный контейнер передайте CSS-селектор.
Использование
<script lang="ts">
import FloatingMenuDemo from '$lib/components/ui/FloatingMenuDemo.svelte';
const menuGroups = [
{
title: 'Продукты',
links: [
{ label: 'Документация', href: '/docs' },
{ label: 'Компоненты', href: '/components' }
]
},
{
title: 'Ресурсы',
variant: 'muted',
links: [
{ label: 'База знаний', href: '/article' },
{ label: 'Статус', href: '/status' }
]
}
];
</script>
<FloatingMenuDemo
menuGroups={menuGroups}
primaryButton={{ label: 'Начать', href: '/docs' }}
>
{#snippet logo()}
<span>Мой проект</span>
{/snippet}
</FloatingMenuDemo><script lang="ts">
import FloatingMenuDemo from '$lib/components/ui/FloatingMenuDemo.svelte';
const menuGroups = [
{
title: 'Продукты',
links: [
{ label: 'Документация', href: '/docs' },
{ label: 'Компоненты', href: '/components' }
]
},
{
title: 'Ресурсы',
variant: 'muted',
links: [
{ label: 'База знаний', href: '/article' },
{ label: 'Статус', href: '/status' }
]
}
];
</script>
<FloatingMenuDemo
menuGroups={menuGroups}
primaryButton={{ label: 'Начать', href: '/docs' }}
>
{#snippet logo()}
<span>Мой проект</span>
{/snippet}
</FloatingMenuDemo>Пропсы
| Пропс | Тип | Описание |
|---|---|---|
menuGroups | MenuGroup[] | Группы ссылок для отображения в меню |
logo | Snippet | Брендинг по центру шапки |
primaryButton | MenuButton | Основная кнопка-ссылка (акцентный цвет) |
secondaryButton | MenuButton | Вторая кнопка-ссылка (скрыта на мобильном) |
classes | FloatingMenuClasses | Классы для отдельных областей меню |
portalTarget | HTMLElement string | Куда переносится меню через портал (по умолчанию "body") |
class | string | Дополнительный класс корневого элемента |
Структура MenuGroup
type MenuGroup = {
title: string;
variant?: "default" | "muted";
links: { label: string; href: string }[];
};type MenuGroup = {
title: string;
variant?: "default" | "muted";
links: { label: string; href: string }[];
};variant: "muted" добавляет группе фоновый цвет, чтобы визуально выделить её среди остальных.
Анимация
Меню использует GSAP для анимации раскрытия: контейнер плавно расширяется, появляется затемнение, линии гамбургера превращаются в крестик, а строки ссылок выезжают снизу с поэтапной задержкой. Анимация реверсируется при закрытии — все шаги проигрываются в обратном порядке.
Поведение и доступность
- Меню открывается и закрывается нажатием на кнопку «Menu».
- Нажатие
Escapeили клик по затемнённой области закрывает меню. - Адаптивная раскладка: на мобильном меню раскрывается на всю ширину, на планшете и десктопе — по центру с ограничением ширины.
- Все интерактивные элементы доступны с клавиатуры.
