Astro Hexagon Menu

Published: September 19, 2023

Astro Hexagon Menu

Table of Contents

The Component

This component creates a menu of hexagon-shaped buttons. It is compatible with TailwindCSS (not required), can be rotated, and can include empty slots.

---
import { cn } from "$/util";

export interface Item {
	link: string;
	label: string;
	active?: boolean;
	itemClasses?: string | string[];
	svgClasses?: string | string[];
	hexagonClasses?: string | string[];
	labelClasses?: string | string[];
	itemColor?:
		| `fill-${string}`
		| `rgb(${number}, ${number}, ${number})`
		| `rgba(${number}, ${number}, ${number}, ${number})`
		| `#${string}`;
	hoverColor?:
		| `hover:fill-${string}`
		| `rgb(${number}, ${number}, ${number})`
		| `rgba(${number}, ${number}, ${number}, ${number})`
		| `#${string}`;
	activeColor?:
		| `[.active]:fill-${string}`
		| `rgb(${number}, ${number}, ${number})`
		| `rgba(${number}, ${number}, ${number}, ${number})`
		| `#${string}`;
	labelColor?:
		| `text-${string}`
		| `rgb(${number}, ${number}, ${number})`
		| `rgba(${number}, ${number}, ${number}, ${number})`
		| `#${string}`;
}

interface Props {
	pathname: string;
	items: (Item | null)[];
	maxLength: number;
	rotated?: boolean;
	menuClasses: string | string[];
	itemClasses?: string | string[];
	svgClasses?: string | string[];
	hexagonClasses?: string | string[];
	labelClasses?: string | string[];
	bgColor?:
		| `fill-${string}`
		| `rgb(${number}, ${number}, ${number})`
		| `rgba(${number}, ${number}, ${number}, ${number})`
		| `#${string}`;
	itemColor?:
		| `fill-${string}`
		| `rgb(${number}, ${number}, ${number})`
		| `rgba(${number}, ${number}, ${number}, ${number})`
		| `#${string}`;
	hoverColor?:
		| `hover:fill-${string}`
		| `rgb(${number}, ${number}, ${number})`
		| `rgba(${number}, ${number}, ${number}, ${number})`
		| `#${string}`;
	activeColor?:
		| `[.active]:fill-${string}`
		| `rgb(${number}, ${number}, ${number})`
		| `rgba(${number}, ${number}, ${number}, ${number})`
		| `#${string}`;
	labelColor?:
		| `text-${string}`
		| `rgb(${number}, ${number}, ${number})`
		| `rgba(${number}, ${number}, ${number}, ${number})`
		| `#${string}`;
}

type CSSVars = {
	"--itemColor"?: string;
	"--hoverColor"?: string;
	"--activeColor"?: string;
	"--labelColor"?: string;
};

const defaultColors = {
	itemColor: "rgb(0, 0, 0)",
	hoverColor: "rgb(80, 80, 80)",
	activeColor: "rgb(80, 80, 80)",
	labelColor: "rgb(255, 255, 255)"
};

const {
	items,
	pathname,
	maxLength = 0,
	rotated = false,
	menuClasses = [],
	itemClasses = [],
	svgClasses = [],
	hexagonClasses = [],
	labelClasses = [],
	itemColor = defaultColors.itemColor,
	hoverColor = defaultColors.hoverColor,
	activeColor = defaultColors.activeColor,
	labelColor = defaultColors.labelColor
} = Astro.props;

const baseClasses = typeof menuClasses === "string" ? menuClasses.split(" ").filter(Boolean) : menuClasses;
if (!baseClasses.find((c) => c.startsWith("[--scale:"))) {
	baseClasses.push("[--scale:1]");
}

const baseHexagonClasses = typeof hexagonClasses === "string" ? hexagonClasses.split(" ").filter(Boolean) : hexagonClasses;
const baseLabelClasses = typeof labelClasses === "string" ? labelClasses.split(" ").filter(Boolean) : labelClasses;
if (itemColor.startsWith("fill-")) baseHexagonClasses.push(itemColor);
if (hoverColor.startsWith("hover:fill-")) baseHexagonClasses.push(hoverColor);
if (activeColor.startsWith("[.active]:fill-")) baseHexagonClasses.push(activeColor);
if (labelColor.startsWith("text-")) baseLabelClasses.push(labelColor);

const cssvars: CSSVars = {};
if (itemColor.startsWith("rgb") || itemColor.startsWith("#")) cssvars["--itemColor"] = itemColor;
if (hoverColor.startsWith("rgb") || hoverColor.startsWith("#")) cssvars["--hoverColor"] = hoverColor;
if (activeColor.startsWith("rgb") || activeColor.startsWith("#")) cssvars["--activeColor"] = activeColor;
if (labelColor.startsWith("rgb") || labelColor.startsWith("#")) cssvars["--labelColor"] = labelColor;

const itemcssvars: Record<number, CSSVars> = {};
items.forEach((it, i) => {
	const {
		itemColor = it?.itemColor && it?.itemColor != defaultColors.itemColor ? it.itemColor : undefined,
		hoverColor = it?.hoverColor && it?.hoverColor != defaultColors.hoverColor ? it.hoverColor : undefined,
		activeColor = it?.activeColor && it?.activeColor != defaultColors.activeColor ? it.activeColor : undefined,
		labelColor = it?.labelColor && it?.labelColor != defaultColors.labelColor ? it.labelColor : undefined,
		...item
	} = it || {
		label: "",
		link: ""
	};
	if (it && item.label) {
		const baseHexagonClasses =
			typeof item.hexagonClasses === "string" ? item.hexagonClasses.split(" ").filter(Boolean) : item.hexagonClasses || [];
		const baseLabelClasses =
			typeof item.labelClasses === "string" ? item.labelClasses.split(" ").filter(Boolean) : item.labelClasses || [];
		if (itemColor?.startsWith("fill-")) baseHexagonClasses.push(itemColor);
		if (hoverColor?.startsWith("hover:fill-")) baseHexagonClasses.push(hoverColor);
		if (activeColor?.startsWith("[.active]:fill-")) baseHexagonClasses.push(activeColor);
		if (labelColor?.startsWith("text-")) baseLabelClasses.push(labelColor);
		item.hexagonClasses = baseHexagonClasses;
		item.labelClasses = baseLabelClasses;

		itemcssvars[i] = {} as CSSVars;
		if (itemColor?.startsWith("rgb") || itemColor?.startsWith("#")) itemcssvars[i]["--itemColor"] = itemColor;
		if (hoverColor?.startsWith("rgb") || hoverColor?.startsWith("#")) itemcssvars[i]["--hoverColor"] = hoverColor;
		if (activeColor?.startsWith("rgb") || activeColor?.startsWith("#")) itemcssvars[i]["--activeColor"] = activeColor;
		if (labelColor?.startsWith("rgb") || labelColor?.startsWith("#")) itemcssvars[i]["--labelColor"] = labelColor;
	}
});

let menuRows: Item[][] = [[]];
items.forEach((item, i) => {
	const rowIndex = menuRows.length - 1;
	if (item)
		menuRows[rowIndex].push({
			...item,
			...(item.link === pathname && { active: true })
		});
	else menuRows[rowIndex].push({ link: "", label: "" });
	const rotDiff = !rotated && menuRows.length % 2 === 0 ? 1 : 0;
	if (maxLength >= 0 && menuRows[rowIndex].length === maxLength - rotDiff && items.length - 1 > i) {
		menuRows.push([]);
	}
});
---
<nav
	class={cn("hex-menu-wrapper", rotated && "rotated", ...baseClasses)}
	style={Object.keys(cssvars).length ? cssvars : undefined}
>
	{
		menuRows.map((row, r) => (
			<div class={cn("hex-menu-row", r % 2 === 1 && !rotated && "shift")}>
				{row.map((item, i) => {
					const BtnEl = item.link ? "a" : "div";
					return (
						<BtnEl
							href={item.link || undefined}
							class={cn(
								"hex-menu-item-container",
								rotated && "rotated",
								item.active && "active",
								item.label && itemClasses,
								item.itemClasses
							)}
							style={Object.keys(itemcssvars[i]).length ? itemcssvars[i] : undefined}
							aria-label={item.label || undefined}
						>
							<svg
								viewBox="0 0 800 800"
								class={cn(
									"hex-menu-item",
									rotated && "rotated",
									!item.label && "empty",
									item.active && "active",
									item.label && svgClasses,
									item.svgClasses
								)}
								aria-hidden={!item.label}
							>
								<g transform={rotated ? "matrix(-6.92 0 0 -6.92 400.24 400.24)" : "matrix(0 6.92 -6.92 0 400.17 400.33)"}>
									<polygon
										class={cn("hex", item.active && "active", item.label && baseHexagonClasses, item.hexagonClasses)}
										points="-19.9,34.5 -39.8,0 -19.9,-34.5 19.9,-34.5 39.8,0 19.9,34.5 "
									/>
								</g>
							</svg>
							<span class={cn("label", item.active && "active", item.label && baseLabelClasses, item.labelClasses)}>
								{item.label}
							</span>
						</BtnEl>
					);
				})}
			</div>
		))
	}
</nav>
<style lang="scss">
	.hex-menu-wrapper {
		display: inline-block;
		.hex-menu-row {
			height: calc(108px * var(--scale));
			position: relative;
			&.shift {
				margin-left: calc(125px / 2 * var(--scale));
			}
		}
		&.rotated {
			.hex-menu-row {
				height: calc(125px * var(--scale));
			}
		}
	}

	.hex-menu-item-container {
		--baseMargin: -37.5px;
		display: inline-block;
		position: relative;
		top: 50%;
		translate: 0 -50%;
		width: calc(200px * var(--scale));
		height: calc(200px * var(--scale));
		margin-left: calc(var(--baseMargin) * var(--scale));
		margin-right: calc(var(--baseMargin) * var(--scale));
		z-index: 1;
		pointer-events: none;
		&:hover {
			z-index: 2;
			.hex-menu-item:not(.empty) {
				.hex {
					fill: var(--hoverColor);
				}
			}
		}
		&.rotated {
			--baseMargin: -46px;
			&:nth-child(2n) {
				top: calc(50% + 125px / 2 * var(--scale));
			}
		}
		.hex-menu-item {
			width: 100%;
			height: 100%;
			pointer-events: none;
			&.active,
			&.empty {
				cursor: default;
			}
			&.empty {
				.hex {
					fill: transparent;
					pointer-events: none;
				}
			}
			&.active {
				.hex {
					fill: var(--activeColor);
				}
			}
			.hex {
				fill: var(--itemColor);
				z-index: -1;
				backface-visibility: hidden;
				transition:
					fill 500ms ease,
					-webkit-transform 1s ease-in-out;
				pointer-events: auto;
				&:hover {
					fill: var(--hoverColor);
				}
			}
		}
		.label {
			color: var(--labelColor);
			text-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
			font-family: sans-serif;
			white-space: nowrap;
			font-size: 1em;
			font-weight: 600;
			letter-spacing: 1px;
			position: absolute;
			top: 50%;
			left: 50%;
			translate: -50% -50%;
			scale: var(--scale);
		}
	}

	.bounce:hover:not(.active):not(.empty) {
		animation: bounce 500ms ease-in-out forwards;
		stroke: rgb(0, 0, 0);
		stroke-width: 0;
		.backface {
			box-shadow: none;
		}
	}
	@keyframes bounce {
		40% {
			scale: 1.2;
			stroke-width: 2;
		}
		60% {
			scale: 1;
		}
		80% {
			scale: 1.05;
			stroke-width: 2;
		}
		100% {
			scale: 1;
		}
	}
</style>

Usage

<HexMenu
  items={[
    { link: "/about", label: "About Me" },
    { link: "/experience", label: "Experience" },
    { link: "/skills", label: "Skills" },
    { link: "/projects", label: "Projects" },
    { link: "/blog", label: "Blog" }
  ]}
  pathname={Astro.url.pathname}
  maxLength={3}
  menuClasses="[--scale:0.8]"
  itemClasses="bounce hover:stroke-[#0004]"
  itemColor="fill-primary"
  hoverColor="hover:fill-accent"
  activeColor="[.active]:fill-accent"
/>

<HexMenu
  items={[
    { link: "/about", label: "About Me" },
    { link: "/experience", label: "Experience" },
    { link: "/skills", label: "Skills" },
    { link: "/projects", label: "Projects" },
    null,
    { link: "/blog", label: "Blog" }
  ]}
  pathname={Astro.url.pathname}
  maxLength={3}
  menuClasses="[--scale:0.8]"
  itemClasses="bounce hover:stroke-[#0004]"
  itemColor="fill-primary"
  hoverColor="hover:fill-accent"
  activeColor="[.active]:fill-accent"
  rotated
/>

Props

NameTypeDefaultDescription
itemsItem[][]An array of items to display in the menu.
pathnamestring""The current path of the page.
maxLengthnumber0The maximum number of items to display in a row.
rotatedbooleanfalseWhether the hexagons should be rotated 90 degrees.
menuClassesstring or string[][]Classes to apply to the menu wrapper.
itemClassesstring or string[][]Classes to apply to all items.
svgClassesstring or string[][]Classes to apply to all SVGs.
hexagonClassesstring or string[][]Classes to apply to all hexagons.
labelClassesstring or string[][]Classes to apply to all labels.
itemColorstring"rgb(0, 0, 0)"The color of the hexagons.
hoverColorstring"rgb(80, 80, 80)"The color of the hexagons when hovered.
activeColorstring"rgb(80, 80, 80)"The color of the hexagons when active.
labelColorstring"rgb(255, 255, 255)"The color of the labels.

Item Props

NameTypeDefaultDescription
linkstring""The link to navigate to when the item is clicked.
labelstring""The label to display on the item.
activebooleanfalseWhether the item is active.
itemClassesstring or string[][]Classes to apply to the item.
svgClassesstring or string[][]Classes to apply to the SVG.
hexagonClassesstring or string[][]Classes to apply to the hexagon.
labelClassesstring or string[][]Classes to apply to the label.
itemColorstring""The color of the hexagon.
hoverColorstring""The color of the hexagon when hovered.
activeColorstring""The color of the hexagon when active.
labelColorstring""The color of the label.