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
/>
Not Rotated
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. |