Navigation
Burger
Hamburger menu button
Preview
size
active
Code
import css from "./Burger.module.css";
type Size = "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface BurgerProps {
size: Size | ResponsiveSize;
"aria-label": string;
"aria-controls"?: string;
active?: boolean;
onClick: () => void;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Burger({
size,
"aria-label": ariaLabel,
"aria-controls": ariaControls,
active,
onClick,
}: BurgerProps) {
return (
<button
type="button"
className={`
${css.burger}
${active ? css.active : ""}
${getSizeClasses(size)}
`}
onClick={onClick}
aria-label={ariaLabel}
aria-expanded={active ?? false}
aria-controls={ariaControls}
>
<span></span>
<span></span>
<span></span>
</button>
);
}
.burger {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: var(--burger-size);
height: var(--burger-size);
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
gap: var(--burger-gap);
background: none;
border: 0;
cursor: pointer;
padding: 0;
/* The box is the 32–48 control ladder; the bars are half as wide and open
two more pixels per tier. */
&.sm {
--burger-size: var(--size-32);
--burger-line: 16px;
--burger-gap: 4px;
}
&.md {
--burger-size: var(--size-40);
--burger-line: 20px;
--burger-gap: 6px;
}
&.lg {
--burger-size: var(--size-48);
--burger-line: 24px;
--burger-gap: 8px;
}
&.sm_base {
--burger-size: var(--size-32);
--burger-line: 16px;
--burger-gap: 4px;
}
&.md_base {
--burger-size: var(--size-40);
--burger-line: 20px;
--burger-gap: 6px;
}
&.lg_base {
--burger-size: var(--size-48);
--burger-line: 24px;
--burger-gap: 8px;
}
@media (min-width: 480px) {
&.sm_mobile {
--burger-size: var(--size-32);
--burger-line: 16px;
--burger-gap: 4px;
}
&.md_mobile {
--burger-size: var(--size-40);
--burger-line: 20px;
--burger-gap: 6px;
}
&.lg_mobile {
--burger-size: var(--size-48);
--burger-line: 24px;
--burger-gap: 8px;
}
}
@media (min-width: 768px) {
&.sm_tablet {
--burger-size: var(--size-32);
--burger-line: 16px;
--burger-gap: 4px;
}
&.md_tablet {
--burger-size: var(--size-40);
--burger-line: 20px;
--burger-gap: 6px;
}
&.lg_tablet {
--burger-size: var(--size-48);
--burger-line: 24px;
--burger-gap: 8px;
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--burger-size: var(--size-32);
--burger-line: 16px;
--burger-gap: 4px;
}
&.md_desktop {
--burger-size: var(--size-40);
--burger-line: 20px;
--burger-gap: 6px;
}
&.lg_desktop {
--burger-size: var(--size-48);
--burger-line: 24px;
--burger-gap: 8px;
}
}
@media (min-width: 1440px) {
&.sm_wide {
--burger-size: var(--size-32);
--burger-line: 16px;
--burger-gap: 4px;
}
&.md_wide {
--burger-size: var(--size-40);
--burger-line: 20px;
--burger-gap: 6px;
}
&.lg_wide {
--burger-size: var(--size-48);
--burger-line: 24px;
--burger-gap: 8px;
}
}
span {
display: block;
width: var(--burger-line);
height: 2px;
background: var(--color-text);
border-radius: var(--radius-pill);
transition:
transform 0.3s ease,
background-color 0.3s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
&.active {
span {
/* The outer bars travel one gap plus their own 2px to land on the middle one. */
&:nth-child(1) {
transform: translateY(calc(var(--burger-gap) + 2px)) rotate(45deg);
}
&:nth-child(2) {
background-color: transparent;
}
&:nth-child(3) {
transform: translateY(calc((var(--burger-gap) + 2px) * -1)) rotate(-45deg);
}
}
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
<script setup lang="ts">
import { computed } from "vue";
type Size = "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface BurgerProps {
size: Size | ResponsiveSize;
ariaLabel: string;
ariaControls?: string;
active?: boolean;
}
const {
size,
ariaLabel,
ariaControls,
} = defineProps<BurgerProps>();
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
</script>
<template>
<button
type="button"
class="burger"
:class="[{ active }, sizeClasses]"
:aria-label="ariaLabel"
:aria-expanded="active"
:aria-controls="ariaControls"
>
<span></span>
<span></span>
<span></span>
</button>
</template>
<style scoped>
.burger {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: var(--burger-size);
height: var(--burger-size);
/* Only the keyboard ring reads it: the box itself paints nothing. */
border-radius: var(--radius-4);
gap: var(--burger-gap);
background: none;
border: 0;
cursor: pointer;
padding: 0;
/* The box is the 32–48 control ladder; the bars are half as wide and open
two more pixels per tier. */
&.sm {
--burger-size: var(--size-32);
--burger-line: 16px;
--burger-gap: 4px;
}
&.md {
--burger-size: var(--size-40);
--burger-line: 20px;
--burger-gap: 6px;
}
&.lg {
--burger-size: var(--size-48);
--burger-line: 24px;
--burger-gap: 8px;
}
&.sm_base {
--burger-size: var(--size-32);
--burger-line: 16px;
--burger-gap: 4px;
}
&.md_base {
--burger-size: var(--size-40);
--burger-line: 20px;
--burger-gap: 6px;
}
&.lg_base {
--burger-size: var(--size-48);
--burger-line: 24px;
--burger-gap: 8px;
}
@media (min-width: 480px) {
&.sm_mobile {
--burger-size: var(--size-32);
--burger-line: 16px;
--burger-gap: 4px;
}
&.md_mobile {
--burger-size: var(--size-40);
--burger-line: 20px;
--burger-gap: 6px;
}
&.lg_mobile {
--burger-size: var(--size-48);
--burger-line: 24px;
--burger-gap: 8px;
}
}
@media (min-width: 768px) {
&.sm_tablet {
--burger-size: var(--size-32);
--burger-line: 16px;
--burger-gap: 4px;
}
&.md_tablet {
--burger-size: var(--size-40);
--burger-line: 20px;
--burger-gap: 6px;
}
&.lg_tablet {
--burger-size: var(--size-48);
--burger-line: 24px;
--burger-gap: 8px;
}
}
@media (min-width: 1024px) {
&.sm_desktop {
--burger-size: var(--size-32);
--burger-line: 16px;
--burger-gap: 4px;
}
&.md_desktop {
--burger-size: var(--size-40);
--burger-line: 20px;
--burger-gap: 6px;
}
&.lg_desktop {
--burger-size: var(--size-48);
--burger-line: 24px;
--burger-gap: 8px;
}
}
@media (min-width: 1440px) {
&.sm_wide {
--burger-size: var(--size-32);
--burger-line: 16px;
--burger-gap: 4px;
}
&.md_wide {
--burger-size: var(--size-40);
--burger-line: 20px;
--burger-gap: 6px;
}
&.lg_wide {
--burger-size: var(--size-48);
--burger-line: 24px;
--burger-gap: 8px;
}
}
span {
display: block;
width: var(--burger-line);
height: 2px;
background: var(--color-text);
border-radius: var(--radius-pill);
transition:
transform 0.3s ease,
background-color 0.3s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
&.active {
span {
/* The outer bars travel one gap plus their own 2px to land on the middle one. */
&:nth-child(1) {
transform: translateY(calc(var(--burger-gap) + 2px)) rotate(45deg);
}
&:nth-child(2) {
background-color: transparent;
}
&:nth-child(3) {
transform: translateY(calc((var(--burger-gap) + 2px) * -1)) rotate(-45deg);
}
}
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | ResponsiveSize | required | Button size (box and strokes). Fixed or responsive. |
| aria-label | string | required | Accessible name of the button (icon-only: without it, a screen reader would announce nothing). |
| aria-controls | string | — | Id of the panel the button opens and closes; without it, nothing says what it controls. |
| active | boolean | — | Open state (morphs to an X, aria-expanded). |
| onClick | () => void | required | Click callback; the parent owns the open state. |