Table Of Contents
A few years ago, creating an animated circular menu that fans out from a center button would have required JavaScript to calculate positions and coordinate animations. Today, modern CSS gives us all the tools we need to build this entirely in the stylesheet. Let’s explore how trigonometric functions, custom properties, and the :has() selector combine to create a sophisticated navigation effect.
View the live demo on CodePen (opens in a new tab)
The Core Effect
When you click the hamburger button, six menu items fan out in a circular arc with staggered animation timing. Simultaneously, the background fades in and begins cycling through the entire color spectrum. The entire effect uses only HTML and CSS—no JavaScript required.
The Foundation: Custom Properties
The implementation starts with a comprehensive set of CSS custom properties that make the entire system configurable:
:root {
--button-size: 80px;
--button-radius: calc(var(--button-size) / 2);
/* Duration variables */
--duration-base: 250ms;
--duration-button: 400ms;
--duration-button-close: 200ms;
--duration-extra: 0.45;
/* Easing variables */
--ease-out: cubic-bezier(0.45, 0.1, 0.43, 1.67);
--ease-menu: cubic-bezier(0.45, 0.1, 0.43, 1.67);
}
This approach makes the timing, sizing, and easing curves easy to adjust. Want the menu to fan out faster? Change --duration-base. Want more dramatic easing? Adjust the cubic-bezier values. This is CSS custom properties at their best—creating a single source of truth for related values.
The Checkbox Hack
The menu uses a hidden checkbox as its state controller:
<input type="checkbox" class="menu-open" id="menu-open" />
<label class="menu-open-button" for="menu-open">
<span class="lines line-1"></span>
<span class="lines line-2"></span>
<span class="lines line-3"></span>
</label>
When the checkbox is checked, CSS can target both the button itself and all the menu items using the adjacent sibling combinator (+) and general sibling combinator (~):
.menu-open:checked + .menu-open-button {
transform: scale(0.8, 0.8);
}
.menu-open:checked ~ .menu-item {
/* Menu items animate outward */
}
This pattern gives us purely declarative state management. The checkbox holds the open/closed state, and CSS selectors do the rest.
Hamburger to X Transformation
The button icon transforms from a hamburger (three horizontal lines) to an X when clicked. Each line is positioned and rotated independently:
.lines {
width: calc(var(--button-size) / 3);
height: 3px;
background: var(--primary-bg);
position: absolute;
top: 50%;
left: 50%;
transition: transform var(--duration-base);
}
.lines:nth-child(1) {
transform: translate3d(0, -8px, 0);
}
.lines:nth-child(3) {
transform: translate3d(0, 8px, 0);
}
.menu-open:checked + .menu-open-button .line-1 {
transform: translate3d(0, 0, 0) rotate(45deg);
}
.menu-open:checked + .menu-open-button .line-2 {
transform: translate3d(0, 0, 0) scale(0.01, 1);
}
.menu-open:checked + .menu-open-button .line-3 {
transform: translate3d(0, 0, 0) rotate(-45deg);
}
The top and bottom lines rotate 45 degrees in opposite directions while moving to center, and the middle line scales down to invisibility. The result is a smooth transformation that clearly communicates the state change.
Circular Positioning with Trigonometry
Here’s where it gets interesting. Each menu item needs to position itself along a circular arc when the menu opens. Modern CSS now includes cos() and sin() functions that make this calculation straightforward.
First, each menu item stores its index using a data attribute: (When available, we can ditch these and use the sibling-index() CSS functions)
<a href="#" data-index="1" class="menu-item blue">...</a>
<a href="#" data-index="2" class="menu-item green">...</a>
Then CSS extracts this value and uses it to calculate the item’s angle:
.menu-item {
--index: attr(data-index type(<number>));
}
.menu-open:checked ~ .menu-item {
--angle: calc(1deg * (90 - (var(--index) - 1) * 60));
--cos: cos(var(--angle));
--sin: sin(var(--angle));
transform: translate3d(
calc(var(--cos) * 100px),
calc(var(--sin) * -100px),
0
);
}
Let’s break down the angle calculation:
- Start at 90 degrees (pointing straight up)
- Subtract 60 degrees for each item beyond the first
- This creates a 300-degree arc (60° × 5 intervals between 6 items)
The cos() function gives us the horizontal position, and sin() gives us the vertical position (negated because CSS Y-coordinates increase downward). Multiply these by 100px to set the radius of the circle.
The beauty of this approach is that it’s completely flexible. Want 8 items instead of 6? Just adjust the angle calculation. Want a larger radius? Change the multiplier. The trigonometric functions handle all the math.
Staggered Animation Timing
When the menu opens, items don’t all appear at once. They cascade outward in sequence, creating a more dynamic effect. This is achieved by calculating each item’s delay based on its index:
.menu-open:checked ~ .menu-item {
transition-duration: var(--duration-base);
transition-delay: calc(
var(--duration-base) * var(--index) * var(--duration-extra)
);
}
The first item (index 1) gets a delay of 250ms × 1 × 0.45 = 112.5ms. The second item gets 250ms × 2 × 0.45 = 225ms, and so on. The --duration-extra multiplier (0.45) controls how much the delays overlap—lower values create more overlap, higher values create more separation.
Dynamic Colors with OKLCH
Each menu item gets a unique color based on its position in the spectrum:
.menu-open:checked ~ .menu-item {
background: oklch(0.7008 0.17 calc(var(--index) * 60));
}
.menu-open:checked ~ .menu-item:hover {
color: oklch(0.68 0.22 calc(var(--index) * 60));
background: oklch(0.96 0.07 calc(var(--index) * 60));
}
The OKLCH color space is particularly well-suited for this because it’s perceptually uniform—colors with the same lightness value actually look equally bright to human eyes. The hue is calculated as index × 60, which distributes six items evenly around the 360-degree color wheel.
Animated Background with @property
The background effect demonstrates one of CSS’s more advanced features: animatable custom properties. To animate a custom property, you must first register it with @property:
@property --bghue {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
This tells the browser that --bghue is a number (not just a string), which allows it to be interpolated during animation. The background overlay is created with a pseudo-element:
body::before {
--h: var(--bghue);
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
circle,
oklch(1 0 0/100%) 0%,
oklch(1 0 0/100%) 40%,
oklch(0.4 0.2 var(--h)/53%) 90%,
oklch(0.1 0.13 var(--h)/53%) 100%
);
opacity: 0;
transition: opacity 3s;
animation: animName 32s linear infinite;
animation-play-state: paused;
}
@keyframes animName {
0% { --bghue: 1; }
50% { --bghue: 360; }
100% { --bghue: 1; }
}
Initially, the overlay is invisible (opacity: 0) and its animation is paused. When the menu opens, we use the :has() selector to detect this and activate the animation:
body:has(.menu-open:checked)::before {
opacity: 0.95;
animation-play-state: running;
}
The --bghue value cycles from 1 to 360 and back over 32 seconds, creating a continuous color rotation through the entire spectrum.
The Power of :has()
The :has() selector is sometimes called the “parent selector,” but it’s more accurately a “conditional selector.” It lets you style an element based on what it contains:
body:has(.menu-open:checked)::before {
opacity: 0.95;
animation-play-state: running;
}
body:has(details[open]) nav {
opacity: 0.1;
}
This eliminates countless cases where we previously needed JavaScript to add classes to parent elements. In this implementation, it coordinates the background animation with the menu state and handles the interaction between the explanatory details elements and the menu.
Extracting Data Attributes with attr()
The menu uses a cutting-edge CSS feature to extract numeric values from data attributes:
.menu-item {
--index: attr(data-index type(<number>));
}
This extracts the value from data-index and explicitly casts it to a number, allowing it to be used in calculations. Previously, this kind of data extraction required JavaScript or manual CSS variable assignment for each element.
Note that this feature requires Chrome 133 or newer, which brings us to browser support.
Collapsible Details with ::details-content
The explanation section uses the relatively new ::details-content pseudo-element combined with calc-size() to create smooth height transitions:
details::details-content {
display: block;
block-size: 0;
overflow: hidden;
transition-property: block-size, content-visibility;
transition-duration: 0.5s;
transition-behavior: allow-discrete;
}
details[open]::details-content {
block-size: calc-size(auto, size);
}
The calc-size() function allows transitioning to auto values, which was previously impossible in CSS. Combined with transition-behavior: allow-discrete, this creates smooth open/close animations for the details elements.
Browser Support Considerations
This implementation uses several cutting-edge CSS features:
Excellent support (all modern browsers):
- CSS custom properties
calc()cos()andsin()oklch()@property:has()
Limited support (Chrome 133+):
attr()with type casting::details-contentcalc-size()transition-behavior: allow-discrete
For the newer features, the menu will still function in older browsers but without the smooth details animations and with a fallback for the attr() functionality. You could provide fallbacks by manually setting the --index variable for each menu item:
.menu-item:nth-child(1) { --index: 1; }
.menu-item:nth-child(2) { --index: 2; }
/* etc. */
Conclusion
This circular navigation menu demonstrates how far CSS has evolved. Trigonometric functions, the :has() selector, animatable custom properties, and the OKLCH color space combine to create an effect that once required JavaScript libraries. The code is declarative, maintainable, and performant.
The implementation is also remarkably flexible. Want different colors? Adjust the OKLCH values. Need more or fewer items? Change the angle calculation. Want faster or slower animations? Modify the custom properties. This is the power of modern CSS—complex effects built on composable, configurable primitives.
Experiment with the full demo on CodePen (opens in a new tab)