Skip to content

Components

Tab buttons

Variants

Underlined

html
<nav class="tabs underlined">
  <div role="tablist" aria-label="Underlined tabs">
    <button id="underlined-tab-1" role="tab" aria-controls="tabpanel-1" aria-selected="true" tabindex="0">
      Profile
    </button>
    <button id="underlined-tab-2" role="tab" aria-controls="tabpanel-2" aria-selected="false" tabindex="-1">
      Settings
    </button>
    <button id="underlined-tab-3" role="tab" aria-controls="tabpanel-3" aria-selected="false" tabindex="-1">
      Notifications
    </button>
  </div>
</nav>

Filled

html
<nav class="tabs filled">
  <div role="tablist" aria-label="Filled tabs">
    <button
      id="filled-tab-1"
      role="tab"
      aria-controls="tabpanel-1"
      aria-selected="true"
      tabindex="0"
    >
      Korg
    </button>
    <button
      id="filled-tab-2"
      role="tab"
      aria-controls="tabpanel-2"
      aria-selected="false"
      tabindex="-1"
    >
      Yamaha
    </button>
    <button
      id="filled-tab-3"
      role="tab"
      aria-controls="tabpanel-3"
      aria-selected="false"
      tabindex="-1"
    >
      Roland
    </button>
  </div>
</nav>

Accessibility

Tab buttons must contain the following:

AttributeValueDescription
role"tab"Identifies the element as a tab
id"TAB_ID"A unique ID for the tab
aria-selected"true/false"Indicates if the tab is selected
aria-controls"panel-id"The ID of the associated tab panel
tabindex"0/-1"Indicates if the tab is focusable

Tab panel

Tab panels must contain the following:

AttributeValueDescription
role"tabpanel"Identifies the element as a tab panel
id"panel-id"A unique ID for the panel
aria-labelledby"unique-tab-id"The ID of the associated tab
hiddenNo value neededIndicates if the panel is hidden (use hidden attribute for non-active panels)

Other

  • Screen reader navigation through proper tabindexing and ARIA attributes, making sure users can switch between tabs and their respective panels.
  • High contrast with distinct visual indicators for active tabs and keyboard focus states.
  • Complete keyboard navigation is implemented, allowing users to move between tabs using arrow keys, Home/End keys, and Tab key, with automatic activation of panels when their associated tabs receive focus.

There's a lot more. Read about it here.

Installation

css
@layer components.base {
  :where(nav.tabs) {
    --_bg-color: transparent;

    & > [role="tablist"] {
      button {
        outline-color: transparent;
        outline-offset: -4px;

        &[aria-selected="true"] {
          &:focus-visible {
            outline: 2px solid var(--text-color-1);
          }
        }
      }
    }

    /* Underlined */
    &.underlined {
      /* Tab list */
      & > [role="tablist"] {
        border-bottom: 1px solid var(--border-color);

        button {
          --_ripple-color: light-dark(
            color-mix(in oklch, var(--gray-2) 80%, var(--color-8)),
            color-mix(in oklch, var(--gray-13) 80%, var(--color-8))
          );

          background: var(--_bg-color) var(--ripple, none);
          font-weight: 500;
          line-height: var(--font-lineheight-4);
          padding: var(--size-2) var(--size-3);

          &:focus-visible {
            border-radius: 0;
          }

          &[aria-selected="true"] {
            border-block-end: 2px solid var(--primary);
            color: var(--primary);
          }

          @media (prefers-reduced-motion: no-preference) {
            transition:
              background-color 0.2s var(--ease-out-3),
              border-color 0.2s var(--ease-out-3),
              color 0.2s var(--ease-out-3),
              outline-offset 0.05s var(--ease-1);

            /*** Ripple effect */
            background-position: center;
            transition: background var(--button-ripple-duration);
            &:where(:not(:active):hover) {
              --ripple: radial-gradient(
                  circle,
                  transparent 1%,
                  var(--_ripple-color) 1%
                )
                center/15000%;
            }

            &:where(:hover:active) {
              background-size: var(--button-ripple-size);
              transition: background 0s;
            }

            &:hover {
              background-color: light-dark(
                oklch(from var(--primary) calc(l * 0.75) none h / 5%),
                oklch(from var(--primary) calc(l * 1.25) none h / 5%)
              );
            }
          }
        }
      }
    }

    /* Filled */
    &.filled {
      --_bg-color: var(--surface-default);
      --_radius: var(--border-radius);
      --_selected-bg: var(--surface-filled);

      & > [role="tablist"] {
        background-color: var(--_bg-color);
        border: var(--border-width) solid var(--border-color);
        border-radius: var(--_radius);
        overflow: hidden;
        padding: 0.792ex;
        width: fit-content;

        button {
          background-color: var(--_bg-color);
          border-radius: var(--_radius);
          line-height: var(--font-lineheight-4);
          padding-inline: var(--size-3);

          &:hover {
            background-color: oklch(from var(--_bg-color) calc(l * 1.25) c h);
          }

          &[aria-selected="true"] {
            background-color: var(--_selected-bg);

            &:hover {
              background-color: oklch(
                from var(--_selected-bg) calc(l * 1.25) c h
              );
            }
          }

          @media (prefers-reduced-motion: no-preference) {
            transition:
              background-color 0.2s var(--ease-out-3),
              border-color 0.2s var(--ease-out-3),
              color 0.2s var(--ease-out-3),
              outline-offset 0.05s var(--ease-1);
          }
        }
      }
    }
  }
}