
Arrow Nav Tabs is a CSS component that renders animated, arrow-shaped navigation tabs for site menus and breadcrumb trails.
The component combines the CSS corner-shape property with asymmetric border-radius values to create beveled arrow ends.
It relies on CSS Grid and the :has() selector to drive fluid width animations when you hover over or focus a tab.
Features:
- Produces arrow-shaped tab visuals using CSS corner geometry and asymmetric border radii.
- Animates per-tab width expansion on hover through grid column proportion changes.
- Tracks hover state independently per tab item through sibling-count-aware CSS selectors.
- Applies a spring-like bounce to the grid transition through a precision-tuned custom easing curve.
- 9 configurable CSS custom properties for colors, spacing, bevel offset, and animation timing.
- Highlights the active tab through URL fragment targeting.
- Adapts to light and dark system color schemes automatically.
- Fallbacks to a visible compatibility notice in browsers that do not support corner shaping.
How to use it:
1. Build a <nav> element containing anchor tags. Set each anchor’s href to point to its own id attribute. This activates the :target state on click and keeps a tab highlighted for as long as that URL fragment stays in the address bar.
<nav> <a href="#dashboard" id="dashboard">Dashboard</a> <a href="#projects" id="projects">Projects</a> <a href="#analytics" id="analytics">Analytics</a> <a href="#settings" id="settings">Settings</a> ... </nav>
2. Apply the CSS. Paste the following styles into your stylesheet or a <style> block. The @layer declarations keep base resets and component styles separate, so they do not override your existing CSS.
/* Declare layer order: base resets load first, component styles override */
@layer base, demo;
@layer demo {
nav {
/* Spring easing curve: defines the bounce behavior of the grid transition.
The linear() function plots progress through dozens of control points,
producing an overshoot-and-settle effect that cubic-bezier cannot replicate. */
--nav-item-trans-easing: linear(
0, 0.009 0.7%, 0.04 1.5%, 0.154 3.1%, 0.801 9%, 1.026 11.5%,
1.171 14%, 1.21 15.2%, 1.233 16.5%, 1.237 18.1%, 1.218 19.8%,
1.004 28.5%, 0.963 31.2%, 0.945 33.9%, 0.948 37.3%, 0.999 46%,
1.013 51.2%, 0.997 68.3%, 1
);
/* Total duration of the grid expansion animation */
--nav-item-trans-duration: 750ms;
/* Default and hover background colors */
--nav-item-bg-color: rgb(29, 41, 61);
--nav-item-bg-color-hover: rgb(69, 85, 108);
/* Default and hover label colors */
--nav-item-text-color: white;
--nav-item-text-color-hover: white;
/* Border width — creates the visual gap between adjacent tabs */
--nav-item-spacing: 2px;
/* Border color matches the page background to produce a clean separator */
--nav-item-border-color: var(--clr-bg);
/* Depth of the diagonal bevel cut on each tab's right edge.
Larger values produce a more pronounced arrow point. */
--nav-item-bevel-offset: 2em;
/* Default 9-column grid layout: leftmost column is compressed */
--grid-cols: .5fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
/* Per-tab hover rules: each one widens the column group under the active tab.
The :has() selector detects which child has hover or keyboard focus. */
&:has(:nth-child(1):hover, :nth-child(1):focus-visible) {
--grid-cols: 1.5fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
}
&:has(:nth-child(2):hover, :nth-child(2):focus-visible) {
--grid-cols: .5fr 1fr 1fr 2fr 1fr 1fr 1fr 1fr 1fr;
}
&:has(:nth-child(3):hover, :nth-child(3):focus-visible) {
--grid-cols: .5fr 1fr 1fr 1fr 1fr 2fr 1fr 1fr 1fr;
}
&:has(:nth-child(4):hover, :nth-child(4):focus-visible) {
--grid-cols: .5fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 2fr;
}
position: relative;
display: grid;
grid-template-columns: var(--grid-cols);
width: min(100%, 800px);
margin-inline: auto;
/* Animate the column recalculation using the spring easing curve above */
transition: grid var(--nav-item-trans-duration) var(--nav-item-trans-easing);
& > a {
position: relative;
/* The asymmetric border-radius creates zero rounding on the left side
and a bevel cut on the right side, forming the arrow point.
corner-shape: bevel renders the radius as a straight diagonal cut
rather than the default curve. */
border-radius: 0 var(--nav-item-bevel-offset) var(--nav-item-bevel-offset) 0 /
0 50% 50% 0;
corner-shape: bevel;
/* This 150ms transition handles the background color swap on hover —
it is intentionally faster than the grid animation for snappy feedback */
transition: all 150ms linear;
border: var(--nav-item-spacing) solid var(--nav-item-border-color);
display: grid;
place-content: center;
padding-block: 1em;
background-color: var(--nav-item-bg-color);
text-decoration: none;
color: var(--nav-item-text-color);
/* Fluid type scale: label size adjusts from small phones to wide monitors */
font-size: clamp(.65rem, 2.5vw + .04rem, 1.4rem);
&:not(:first-child) {
/* Left padding clears the bevel overhang from the previous tab,
keeping label text horizontally clear of the overlap area */
padding-left: var(--nav-item-bevel-offset);
}
/* Active states: applied on hover, keyboard focus, and :target match.
:target activates when the URL fragment equals this anchor's id. */
&:target,
&:hover,
&:focus-visible {
color: var(--nav-item-text-color-hover);
background-color: var(--nav-item-bg-color-hover);
outline: none;
}
/* All tabs sit on row 1. Each spans 3 columns with a 2-column overlap.
z-index decreases left to right so earlier tabs appear on top. */
grid-row: 1;
&:nth-child(1) { grid-column: 1 / span 3; z-index: 4; }
&:nth-child(2) { grid-column: 3 / span 3; z-index: 3; }
&:nth-child(3) { grid-column: 5 / span 3; z-index: 2; }
&:nth-child(4) { grid-column: 7 / span 3; z-index: 1; }
}
}
}
/* Base resets and color tokens for light/dark mode */
@layer base {
* {
box-sizing: border-box;
}
:root {
color-scheme: light dark;
/* Page background tokens for each scheme */
--bg-dark: rgb(12 10 9);
--bg-light: rgb(248 245 238);
--txt-light: rgb(10 10 10);
--txt-dark: rgb(245 245 245);
/* Resolved tokens: the browser picks the correct value per active scheme */
--clr-bg: light-dark(var(--bg-light), var(--bg-dark));
--clr-txt: light-dark(var(--txt-light), var(--txt-dark));
--clr-lines: light-dark(rgba(0 0 0 / .25), rgba(255 255 255 / .25));
}
body {
background-color: var(--clr-bg);
color: var(--clr-txt);
min-height: 100svh;
margin: 0;
padding: 2rem;
font-family: "Jura", sans-serif;
font-size: 1rem;
line-height: 1.5;
display: grid;
place-items: center;
gap: 2rem;
/* Fallback banner: visible in browsers that do not support corner-shape.
Uses @supports to detect the feature and inject a fixed-position warning. */
@supports not (corner-shape: notch) {
&::before {
content: "Your browser does not support corner-shape. Arrow shapes will not render correctly.";
position: fixed;
top: 2rem;
left: 50%;
translate: -50% -50%;
font-size: 0.8rem;
color: white;
background-color: red;
padding: .25em 1em;
}
}
}
}3. CSS Custom Properties Reference:
--nav-item-bg-color(color): Sets the default background color for each tab.--nav-item-bg-color-hover(color): Sets the background color for a tab in hover, focus, or active state.--nav-item-text-color(color): Sets the default label color for tab items.--nav-item-text-color-hover(color): Sets the label color when a tab is hovered, focused, or targeted.--nav-item-spacing(length): Controls the border width that creates the visual gap between adjacent tabs.--nav-item-border-color(color): Sets the border color. Matches the page background by default to produce a clean separator line.--nav-item-bevel-offset(length): Controls the depth of the diagonal bevel cut on each tab’s right edge. Larger values create a sharper, more pronounced arrow point.--nav-item-trans-duration(time): Sets the duration of the grid expansion animation triggered on hover.--nav-item-trans-easing(easing): Defines the timing curve for the grid animation. Replace it with any valid CSS easing value to change the motion feel.
FAQs:
Q: How do I add a fifth tab?
A: Extend the grid to 11 columns and add a new :has(:nth-child(5):hover) rule that widens the column group under the fifth tab. Set its grid-column to 9 / span 3 and assign it z-index: 0. The HTML only needs a fifth anchor added to the <nav>.
Q: How do I use this in a React or Vue component?
A: Copy the CSS into your component’s stylesheet or a CSS module. React and Vue users can render the <nav> and anchor children directly inside their JSX or template.
Q: How do I set a tab as permanently active on page load?
A: Load the page with the relevant URL fragment already in the address bar (for example, yoursite.com/page#analytics). The :target pseudo-class matches automatically on load.
Q: Can I reverse the bevel to point left?
A: Swap the zero and offset values in the border-radius shorthand so the left horizontal radii receive var(--nav-item-bevel-offset) and the right ones receive zero. The corner-shape: bevel rule cuts the left corners instead. Adjust the padding-right on non-last items to compensate for the new overlap side.







