Astro Hexagon Menu

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.

astro
---
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

astro
<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

Name Type Default Description
items Item[] [] An array of items to display in the menu.
pathname string "" The current path of the page.
maxLength number 0 The maximum number of items to display in a row.
rotated boolean false Whether the hexagons should be rotated 90 degrees.
menuClasses string or string[] [] Classes to apply to the menu wrapper.
itemClasses string or string[] [] Classes to apply to all items.
svgClasses string or string[] [] Classes to apply to all SVGs.
hexagonClasses string or string[] [] Classes to apply to all hexagons.
labelClasses string or string[] [] Classes to apply to all labels.
itemColor string "rgb(0, 0, 0)" The color of the hexagons.
hoverColor string "rgb(80, 80, 80)" The color of the hexagons when hovered.
activeColor string "rgb(80, 80, 80)" The color of the hexagons when active.
labelColor string "rgb(255, 255, 255)" The color of the labels.

Item Props

Name Type Default Description
link string "" The link to navigate to when the item is clicked.
label string "" The label to display on the item.
active boolean false Whether the item is active.
itemClasses string or string[] [] Classes to apply to the item.
svgClasses string or string[] [] Classes to apply to the SVG.
hexagonClasses string or string[] [] Classes to apply to the hexagon.
labelClasses string or string[] [] Classes to apply to the label.
itemColor string "" The color of the hexagon.
hoverColor string "" The color of the hexagon when hovered.
activeColor string "" The color of the hexagon when active.
labelColor string "" The color of the label.