Перейти к содержимому

Интерфейс

Плавающее меню

Всплывающее меню с анимированным раскрытием и адаптивным дизайном.


Плавающее меню

Плавающее навигационное меню с анимированным раскрытием. На мобильных раскрывается на весь экран, на десктопе — компактное всплывающее меню. Поддерживает группы ссылок, кастомный контент по центру и действия справа.

Превью

opensophy
<script lang="ts">
	/* eslint-disable svelte/no-navigation-without-resolve */
	import { type ClassValue } from "clsx";
	import type { Snippet } from "svelte";
	import { cn } from "../../utils/cn";
	import AreaRangeSolid from "carbon-icons-svelte/lib/AreaRangeSolid.svelte";
	import Close from "carbon-icons-svelte/lib/Close.svelte";

	type MenuVariant = "default" | "muted";

	type MenuLink = {
		label: string;
		href: string;
		onclick?: (e: MouseEvent) => void;
	}

	type MenuButton = {
		label: string;
		href: string;
	}

	type MenuGroup = {
		title: string;
		variant?: MenuVariant;
		links: MenuLink[];
	}

	type FloatingMenuClasses = {
		root?: ClassValue;
		overlay?: ClassValue;
		header?: ClassValue;
		toggleButton?: 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[];
		centerContent?: Snippet;
		actionsEnd?: Snippet;
		primaryButton?: MenuButton;
		secondaryButton?: MenuButton;
		class?: string;
		classes?: FloatingMenuClasses;
	}

	let {
		menuGroups,
		centerContent,
		actionsEnd,
		primaryButton,
		secondaryButton,
		class: className,
		classes,
	}: Props = $props();

	let isOpen = $state(false);

	function toggle() {
		isOpen = !isOpen;
	}
</script>

<svelte:body style:overflow={isOpen ? "hidden" : undefined} />

<!-- Backdrop overlay -->
{#if isOpen}
	<div
		data-slot="overlay"
		class={cn(
			"fixed inset-0 z-40 bg-background-inset/90",
			classes?.overlay,
		)}
		onclick={toggle}
		onkeydown={(e) => {
			if (e.key === "Escape") {
				e.preventDefault();
				toggle();
			}
		}}
		role="button"
		tabindex="-1"
		aria-label="Close menu"
	></div>
{/if}

<!-- Floating nav container -->
<div
	data-slot="root"
	class={cn(
		"floating-menu fixed top-2 left-1/2 z-50 w-full max-w-[95vw] -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]",
		isOpen && "floating-menu-open",
		className,
		classes?.root,
	)}
>
	<div
		data-slot="header"
		class={cn(
			"relative z-20 flex w-full items-center justify-between px-1 py-1",
			classes?.header,
		)}
	>
		<!-- Left: hamburger toggle with pure CSS animation -->
		<button
			onclick={toggle}
			data-slot="toggle-button"
			data-open={isOpen}
			class={cn(
				"inset-shadow transition-scale relative inline-flex size-9 items-center justify-center rounded-sm bg-background-inset text-foreground duration-150 ease-out active:scale-[0.95]",
				classes?.toggleButton,
			)}
			aria-expanded={isOpen}
			aria-label={isOpen ? "Закрыть меню" : "Открыть меню"}
		>
			<span class="menu-toggle-icon menu-toggle-open">
				<AreaRangeSolid size={16} />
			</span>
			<span class="menu-toggle-icon menu-toggle-close">
				<Close size={16} />
			</span>
		</button>

		<!-- Center: custom content -->
		<div
			class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform-gpu"
			style="backface-visibility: hidden;"
		>
			{#if centerContent}
				<div data-slot="center-content">
					{@render centerContent()}
				</div>
			{/if}
		</div>

		<!-- Right: actions -->
		<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-9 items-center justify-center rounded-sm px-3 text-xs font-medium text-foreground transition-[background-color,color] duration-300 hover:bg-background-muted md:flex",
						classes?.secondaryButton,
					)}
				>
					{secondaryButton.label}
				</a>
			{/if}
			{#if primaryButton}
				<a
					href={primaryButton.href}
					data-slot="primary-button"
					class={cn(
						"flex h-9 items-center justify-center rounded-sm bg-accent/10 px-3 text-xs font-medium text-accent transition-[background-color] duration-300 hover:bg-accent/20",
						classes?.primaryButton,
					)}
				>
					{primaryButton.label}
				</a>
			{/if}
			{#if actionsEnd}
				{@render actionsEnd()}
			{/if}
		</div>
	</div>

	<!-- Dropdown menu -->
	<div
		data-slot="menu-wrapper"
		class={cn(
			"menu-wrapper w-full overflow-hidden border-t border-border",
			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",
						group.variant === "muted" ? "bg-background-muted" : "bg-transparent",
						classes?.group,
						group.variant === "muted" && classes?.groupMuted,
					)}
				>
					<h3
						data-slot="group-title"
						class={cn(
							"text-xs font-medium tracking-wider text-foreground-muted/50 uppercase",
							classes?.groupTitle,
						)}
					>
						{group.title}
					</h3>
					<div class="mt-2 flex flex-col gap-4">
						{#each group.links as link, i (link.href + link.label)}
							<a
								href={link.href}
								onclick={link.onclick}
								data-slot="link"
								class={cn(
									"menu-link group/link relative block w-fit text-2xl font-normal text-foreground-muted transition-colors duration-300 hover:text-foreground",
									classes?.link,
								)}
								style="--delay: {i * 40}ms"
							>
								<span class="relative z-10 block leading-tight">
									<span
										data-slot="link-text"
										class={cn("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-300 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>

<style>
	.menu-toggle-icon {
		position: absolute;
		opacity: 0;
		filter: blur(4px);
		scale: 0.25;
		transition:
			opacity 150ms ease-out,
			filter 150ms ease-out,
			scale 150ms ease-out;
		will-change: opacity, filter, scale;
	}

	.menu-toggle-open {
		opacity: 1;
		filter: blur(0);
		scale: 1;
	}

	[data-open="true"] .menu-toggle-open {
		opacity: 0;
		filter: blur(4px);
		scale: 0.25;
	}

	[data-open="true"] .menu-toggle-close {
		opacity: 1;
		filter: blur(0);
		scale: 1;
	}

	/* Menu wrapper */
	.menu-wrapper {
		max-height: 0;
		opacity: 0;
		transition:
			max-height 400ms cubic-bezier(0.4, 0, 0.2, 1),
			opacity 200ms ease-out;
	}

	.floating-menu-open .menu-wrapper {
		max-height: 70vh;
		opacity: 1;
	}

	/* Menu links stagger */
	.menu-link {
		opacity: 0;
		transform: translateY(16px);
		transition:
			opacity 200ms ease-out,
			transform 300ms cubic-bezier(0.4, 0, 0.2, 1),
			color 300ms ease-out;
		transition-delay: var(--delay, 0ms);
	}

	.floating-menu-open .menu-link {
		opacity: 1;
		transform: translateY(0);
	}

	/* Mobile adjustments */
	@media (max-width: 767px) {
		.floating-menu {
			transition:
				max-width 400ms cubic-bezier(0.4, 0, 0.2, 1),
				top 400ms cubic-bezier(0.4, 0, 0.2, 1),
				padding-top 400ms cubic-bezier(0.4, 0, 0.2, 1),
				border-radius 400ms cubic-bezier(0.4, 0, 0.2, 1);
		}

		.floating-menu-open {
			top: 0 !important;
			max-width: 100% !important;
			padding-top: 0.5rem;
			border-top-left-radius: 0;
			border-top-right-radius: 0;
		}
	}
</style>
<script lang="ts">
	/* eslint-disable svelte/no-navigation-without-resolve */
	import { type ClassValue } from "clsx";
	import type { Snippet } from "svelte";
	import { cn } from "../../utils/cn";
	import AreaRangeSolid from "carbon-icons-svelte/lib/AreaRangeSolid.svelte";
	import Close from "carbon-icons-svelte/lib/Close.svelte";

	type MenuVariant = "default" | "muted";

	type MenuLink = {
		label: string;
		href: string;
		onclick?: (e: MouseEvent) => void;
	}

	type MenuButton = {
		label: string;
		href: string;
	}

	type MenuGroup = {
		title: string;
		variant?: MenuVariant;
		links: MenuLink[];
	}

	type FloatingMenuClasses = {
		root?: ClassValue;
		overlay?: ClassValue;
		header?: ClassValue;
		toggleButton?: 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[];
		centerContent?: Snippet;
		actionsEnd?: Snippet;
		primaryButton?: MenuButton;
		secondaryButton?: MenuButton;
		class?: string;
		classes?: FloatingMenuClasses;
	}

	let {
		menuGroups,
		centerContent,
		actionsEnd,
		primaryButton,
		secondaryButton,
		class: className,
		classes,
	}: Props = $props();

	let isOpen = $state(false);

	function toggle() {
		isOpen = !isOpen;
	}
</script>

<svelte:body style:overflow={isOpen ? "hidden" : undefined} />

<!-- Backdrop overlay -->
{#if isOpen}
	<div
		data-slot="overlay"
		class={cn(
			"fixed inset-0 z-40 bg-background-inset/90",
			classes?.overlay,
		)}
		onclick={toggle}
		onkeydown={(e) => {
			if (e.key === "Escape") {
				e.preventDefault();
				toggle();
			}
		}}
		role="button"
		tabindex="-1"
		aria-label="Close menu"
	></div>
{/if}

<!-- Floating nav container -->
<div
	data-slot="root"
	class={cn(
		"floating-menu fixed top-2 left-1/2 z-50 w-full max-w-[95vw] -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]",
		isOpen && "floating-menu-open",
		className,
		classes?.root,
	)}
>
	<div
		data-slot="header"
		class={cn(
			"relative z-20 flex w-full items-center justify-between px-1 py-1",
			classes?.header,
		)}
	>
		<!-- Left: hamburger toggle with pure CSS animation -->
		<button
			onclick={toggle}
			data-slot="toggle-button"
			data-open={isOpen}
			class={cn(
				"inset-shadow transition-scale relative inline-flex size-9 items-center justify-center rounded-sm bg-background-inset text-foreground duration-150 ease-out active:scale-[0.95]",
				classes?.toggleButton,
			)}
			aria-expanded={isOpen}
			aria-label={isOpen ? "Закрыть меню" : "Открыть меню"}
		>
			<span class="menu-toggle-icon menu-toggle-open">
				<AreaRangeSolid size={16} />
			</span>
			<span class="menu-toggle-icon menu-toggle-close">
				<Close size={16} />
			</span>
		</button>

		<!-- Center: custom content -->
		<div
			class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform-gpu"
			style="backface-visibility: hidden;"
		>
			{#if centerContent}
				<div data-slot="center-content">
					{@render centerContent()}
				</div>
			{/if}
		</div>

		<!-- Right: actions -->
		<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-9 items-center justify-center rounded-sm px-3 text-xs font-medium text-foreground transition-[background-color,color] duration-300 hover:bg-background-muted md:flex",
						classes?.secondaryButton,
					)}
				>
					{secondaryButton.label}
				</a>
			{/if}
			{#if primaryButton}
				<a
					href={primaryButton.href}
					data-slot="primary-button"
					class={cn(
						"flex h-9 items-center justify-center rounded-sm bg-accent/10 px-3 text-xs font-medium text-accent transition-[background-color] duration-300 hover:bg-accent/20",
						classes?.primaryButton,
					)}
				>
					{primaryButton.label}
				</a>
			{/if}
			{#if actionsEnd}
				{@render actionsEnd()}
			{/if}
		</div>
	</div>

	<!-- Dropdown menu -->
	<div
		data-slot="menu-wrapper"
		class={cn(
			"menu-wrapper w-full overflow-hidden border-t border-border",
			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",
						group.variant === "muted" ? "bg-background-muted" : "bg-transparent",
						classes?.group,
						group.variant === "muted" && classes?.groupMuted,
					)}
				>
					<h3
						data-slot="group-title"
						class={cn(
							"text-xs font-medium tracking-wider text-foreground-muted/50 uppercase",
							classes?.groupTitle,
						)}
					>
						{group.title}
					</h3>
					<div class="mt-2 flex flex-col gap-4">
						{#each group.links as link, i (link.href + link.label)}
							<a
								href={link.href}
								onclick={link.onclick}
								data-slot="link"
								class={cn(
									"menu-link group/link relative block w-fit text-2xl font-normal text-foreground-muted transition-colors duration-300 hover:text-foreground",
									classes?.link,
								)}
								style="--delay: {i * 40}ms"
							>
								<span class="relative z-10 block leading-tight">
									<span
										data-slot="link-text"
										class={cn("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-300 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>

<style>
	.menu-toggle-icon {
		position: absolute;
		opacity: 0;
		filter: blur(4px);
		scale: 0.25;
		transition:
			opacity 150ms ease-out,
			filter 150ms ease-out,
			scale 150ms ease-out;
		will-change: opacity, filter, scale;
	}

	.menu-toggle-open {
		opacity: 1;
		filter: blur(0);
		scale: 1;
	}

	[data-open="true"] .menu-toggle-open {
		opacity: 0;
		filter: blur(4px);
		scale: 0.25;
	}

	[data-open="true"] .menu-toggle-close {
		opacity: 1;
		filter: blur(0);
		scale: 1;
	}

	/* Menu wrapper */
	.menu-wrapper {
		max-height: 0;
		opacity: 0;
		transition:
			max-height 400ms cubic-bezier(0.4, 0, 0.2, 1),
			opacity 200ms ease-out;
	}

	.floating-menu-open .menu-wrapper {
		max-height: 70vh;
		opacity: 1;
	}

	/* Menu links stagger */
	.menu-link {
		opacity: 0;
		transform: translateY(16px);
		transition:
			opacity 200ms ease-out,
			transform 300ms cubic-bezier(0.4, 0, 0.2, 1),
			color 300ms ease-out;
		transition-delay: var(--delay, 0ms);
	}

	.floating-menu-open .menu-link {
		opacity: 1;
		transform: translateY(0);
	}

	/* Mobile adjustments */
	@media (max-width: 767px) {
		.floating-menu {
			transition:
				max-width 400ms cubic-bezier(0.4, 0, 0.2, 1),
				top 400ms cubic-bezier(0.4, 0, 0.2, 1),
				padding-top 400ms cubic-bezier(0.4, 0, 0.2, 1),
				border-radius 400ms cubic-bezier(0.4, 0, 0.2, 1);
		}

		.floating-menu-open {
			top: 0 !important;
			max-width: 100% !important;
			padding-top: 0.5rem;
			border-top-left-radius: 0;
			border-top-right-radius: 0;
		}
	}
</style>

Нажмите на кнопку-гамбургер — меню раскроется внутри контейнера.

Особенности

  • Floating-режим: фиксированное положение относительно ближайшего трансформируемого предка
  • Анимированное появление ссылок с задержкой (stagger)
  • Группы ссылок с вариантами оформления (default / muted)
  • Полноэкранный режим на мобильных
  • Слот centerContent для логотипа или названия бренда
  • Слот actionsEnd для кнопок (ThemeToggle и др.)
  • Поддержка onclick на ссылках для модальных окон

Использование

<script>
	import FloatingMenu from '$lib/components/ui/FloatingMenu.svelte';
	import ThemeToggle from '$lib/components/ui/ThemeToggle.svelte';
</script>

<FloatingMenu
	menuGroups={[
		{
			title: 'Платформа',
			links: [
				{ label: 'Документация', href: '/docs' },
				{ label: 'Статьи', href: '/article' }
			]
		}
	]}
>
	{#snippet centerContent()}
		<span class="font-medium">Бренд</span>
	{/snippet}
	{#snippet actionsEnd()}
		<ThemeToggle />
	{/snippet}
</FloatingMenu>
<script>
	import FloatingMenu from '$lib/components/ui/FloatingMenu.svelte';
	import ThemeToggle from '$lib/components/ui/ThemeToggle.svelte';
</script>

<FloatingMenu
	menuGroups={[
		{
			title: 'Платформа',
			links: [
				{ label: 'Документация', href: '/docs' },
				{ label: 'Статьи', href: '/article' }
			]
		}
	]}
>
	{#snippet centerContent()}
		<span class="font-medium">Бренд</span>
	{/snippet}
	{#snippet actionsEnd()}
		<ThemeToggle />
	{/snippet}
</FloatingMenu>

Пропсы

Пропс Тип Описание
menuGroups MenuGroup[] Массив групп ссылок
centerContent Snippet Контент по центру шапки
actionsEnd Snippet Действия справа (иконки, кнопки)
primaryButton MenuButton Основная кнопка-ссылка
secondaryButton MenuButton Вторичная кнопка-ссылка
class string Дополнительные CSS-классы

Структура MenuGroup

interface MenuGroup {
	title: string;
	variant?: 'default' | 'muted'; // muted — с фоном
	links: MenuLink[];
}

interface MenuLink {
	label: string;
	href: string;
	onclick?: (e: MouseEvent) => void;
}
interface MenuGroup {
	title: string;
	variant?: 'default' | 'muted'; // muted — с фоном
	links: MenuLink[];
}

interface MenuLink {
	label: string;
	href: string;
	onclick?: (e: MouseEvent) => void;
}

Важно о position: fixed

FloatingMenu использует position: fixed, поэтому он позиционируется относительно ближайшего предка с CSS transform. Для использования внутри контейнера оберните в элемент с transform: translateZ(0):

<div style="transform: translateZ(0); position: relative; overflow: hidden;">
	<FloatingMenu ... />
</div>
<div style="transform: translateZ(0); position: relative; overflow: hidden;">
	<FloatingMenu ... />
</div>