CSS corner-shape Arrow Tabs for Menus & Breadcrumbs

Category: CSS & CSS3 , Menu & Navigation | April 13, 2026
Authorcbolson
Last UpdateApril 13, 2026
LicenseMIT
Tags
Views0 views
CSS corner-shape Arrow Tabs for Menus & Breadcrumbs

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.

You Might Be Interested In:


Leave a Reply