siteHeader--fixed.html
<header class="siteHeader -fixed" v-scroll-header>
  <div class="container">
    <div class="siteHeader__content">
    <a class="siteHeader__logo" href="">
      <svg viewBox="0 0 300 133" fill="none" xmlns="http://www.w3.org/2000/svg">
        <rect width="300" height="133" fill="currentColor"/>
      </svg>
    </a>
    <nav class="siteHeader__utilityNav navigation">
      <ul class="navigation__list">
        <li class="navigation__item">
          <p-openable class="navigation__button" label="Utility Dropdown" :close-on-blur="false">
            <template #default>
              <div class="navigation__dropdown dropdown">
                <ul class="dropdown__list">
                  <li class="dropdown__item">
                    <a class="dropdown__link" href=""><span>Elit?</span></a>
                  </li>
                  <li class="dropdown__item">
                    <a class="dropdown__link" href=""><span>Dolor.</span></a>
                  </li>
                  <li class="dropdown__item">
                    <a class="dropdown__link" href=""><span>Adipisicing!</span></a>
                  </li>
                  <li class="dropdown__item">
                    <a class="dropdown__link" href=""><span>Amet</span></a>
                  </li>
                  <li class="dropdown__item">
                    <a class="dropdown__link" href=""><span>Adipisicing</span></a>
                  </li>
                </ul>
              </div>
            </template>
          </p-openable>
        </li>
        <li class="navigation__item">
          <a class="navigation__link" href="#">Contact</a>
        </li>
      </ul>
    </nav>
    <div class="siteHeader__search search">
      <svg class="search__icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256"><path d="M232.49,215.51,185,168a92.12,92.12,0,1,0-17,17l47.53,47.54a12,12,0,0,0,17-17ZM44,112a68,68,0,1,1,68,68A68.07,68.07,0,0,1,44,112Z"></path></svg>
      <input class="search__input input" type="search" placeholder="Search">
    </div>
    <nav class="siteHeader__mobileNav mobileNavigation">
      <p-openable class="mobileNavigation__menu button -circle -ghost" label="menu">
        <template #toggle>
            <svg class="button__icon icon"><use href="/main-icons-sprite.svg#bars" /></svg>
        </template>
        <template v-slot="{ click }">
          <div class="mobileNavigation__pane" v-scrolllock>
            <div class="mobileNavigation__title">
              Menu
            </div>
            <ul class="mobileNavigation__list" v-directionals>
              <li class="dropdown__item">
                <p-openable class="mobileNavigation__button" name="submenu">
                  <template #toggle>
                    Submenu
                    <svg class="icon"><use href="/main-icons-sprite.svg#chevron-right" /></svg>
                  </template>
                  <template v-slot="{ click }">
                    <div class="mobileNavigation__pane">
                      <div class="mobileNavigation__title">
                        <div class="mobileNavigation__actions">
                          <button class="button -circle -ghost" @click.stopPropagation="click" tabindex="-1">
                            <svg class="icon"><use href="/main-icons-sprite.svg#chevron-left" /></svg>
                          </button>
                        </div>
                        Menu
                      </div>
                      <ul class="mobileNavigation__list" v-directionals>
                        <li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Consectetur asperiores</a></li>
                        <li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Consectetur asperiores</a></li>
                        <li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Amet porro.</a></li>
                        <li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Ipsum dolorem</a></li>
                      </ul>
                    </div>
                  </template>
                </p-openable>
              </li>
              <li class="dropdown__item">
                <p-openable class="mobileNavigation__button" name="submenu">
                  <template #toggle>
                    STUFF
                    <svg class="icon"><use href="/main-icons-sprite.svg#chevron-right" /></svg>
                  </template>
                  <template v-slot="{ click }">
                    <div class="mobileNavigation__pane">
                      <div class="mobileNavigation__title">
                        <div class="mobileNavigation__actions">
                          <button class="button -circle -ghost" @click.stopPropagation="click" tabindex="-1">
                            <svg class="icon"><use href="/main-icons-sprite.svg#chevron-left" /></svg>
                          </button>
                        </div>
                        Menu
                      </div>
                      <ul class="mobileNavigation__list" v-directionals>
                        <li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Consectetur asperiores</a></li>
                        <li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Consectetur asperiores</a></li>
                        <li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Amet porro.</a></li>
                        <li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Ipsum dolorem</a></li>
                      </ul>
                    </div>
                  </template>
                </p-openable>
              </li>
              <li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Consectetur asperiores</a></li>
              <li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Amet porro.</a></li>
              <li class="mobileNavigation__item"><a href="" class="mobileNavigation__link">Ipsum dolorem</a></li>
              <li class="mobileNavigation__item">
                <a class="mobileNavigation__link" href="">Amet impedit cumque</a>
              </li>
              <li class="mobileNavigation__item">
                <a class="mobileNavigation__link" href="">Consectetur vitae temporibus!</a>
              </li>
              <li class="mobileNavigation__item">
                <a class="mobileNavigation__link" href="">Consectetur doloremque reiciendis</a>
              </li>
              <li class="mobileNavigation__item">
                <a class="mobileNavigation__link" href="">Dolor pariatur rerum.</a>
              </li>
              <li class="mobileNavigation__item">
                <a class="mobileNavigation__link" href="">Ipsum a officiis.</a>
              </li>
              <li class="mobileNavigation__item">
                <a class="mobileNavigation__link" href="">Dolor unde debitis.</a>
              </li>
              <li class="mobileNavigation__item">
                <a class="mobileNavigation__link" href="">Adipisicing delectus velit</a>
              </li>
              <li class="mobileNavigation__item">
                <a class="mobileNavigation__link" href="">Amet aliquam voluptates</a>
              </li>
            </ul>
          </div>
          <div class="mobileNavigation__closeButton">
            <button class="button -circle -ghost" @click="click">
              <svg class="icon"><use href="/main-icons-sprite.svg#close" /></svg>
            </button>
          </div>
        </template>
      </p-openable>
    </nav>

    <nav class="siteHeader__primaryNav navigation" v-directionals>
      <ul class="navigation__list">
        <li class="navigation__item" v-directionals>
          <p-openable class="navigation__button" label="Default Dropdown" :close-on-blur="false">
            <template #default>
              <div class="navigation__dropdown dropdown">
                <ul class="dropdown__list">
                  <li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
                  <li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
                  <li class="dropdown__item"><a class="dropdown__link" href=""><span>Amet porro.</span></a></li>
                  <li class="dropdown__item"><a class="dropdown__link" href=""><span>Ipsum dolorem</span></a></li>
                </ul>
              </div>
            </template>
          </p-openable>
        </li>
        <li class="navigation__item" v-directionals>
          <p-openable class="navigation__button" label="Centered Dropdown" :close-on-blur="false">
            <template #default>
              <div class="navigation__dropdown dropdown -center">
                <ul class="dropdown__list">
                  <li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
                  <li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
                  <li class="dropdown__item"><a class="dropdown__link" href=""><span>Amet porro.</span></a></li>
                  <li class="dropdown__item"><a class="dropdown__link" href=""><span>Ipsum dolorem</span></a></li>
                </ul>
              </div>
            </template>
          </p-openable>
        </li>
        <li class="navigation__item -wide" v-directionals>
          <p-openable class="navigation__button" label="Full-width Dropdown" :close-on-blur="false">
            <template #default>
              <div class="navigation__dropdown dropdown grid -columns-3">
                <div class="cell">
                  <h3>Heading</h3>
                  <p>
                    Adipisicing obcaecati tempora qui ullam voluptates beatae? Assumenda soluta quae
                  </p>
                  <ul class="dropdown__list">
                    <li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
                    <li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
                    <li class="dropdown__item"><a class="dropdown__link" href=""><span>Amet porro.</span></a></li>
                    <li class="dropdown__item"><a class="dropdown__link" href=""><span>Ipsum dolorem</span></a></li>
                  </ul>
                </div>
                <div class="cell">
                  <h3>Heading</h3>
                  <p>
                    Adipisicing obcaecati tempora qui ullam voluptates beatae? Assumenda soluta quae
                  </p>
                  <ul class="dropdown__list">
                    <li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
                    <li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
                    <li class="dropdown__item"><a class="dropdown__link" href=""><span>Amet porro.</span></a></li>
                    <li class="dropdown__item"><a class="dropdown__link" href=""><span>Ipsum dolorem</span></a></li>
                  </ul>
                </div>
                <div class="cell">
                  <h3>Heading</h3>
                  <p>
                    Adipisicing obcaecati tempora qui ullam voluptates beatae? Assumenda soluta quae
                  </p>
                  <ul class="dropdown__list">
                    <li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
                    <li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
                    <li class="dropdown__item"><a class="dropdown__link" href=""><span>Amet porro.</span></a></li>
                    <li class="dropdown__item"><a class="dropdown__link" href=""><span>Ipsum dolorem</span></a></li>
                  </ul>
                </div>
              </div>
            </template>
          </p-openable>
        </li>
        <li class="navigation__item" v-directionals>
          <p-openable class="navigation__button" label="Right Aligned Dropdown" :close-on-blur="false">
            <template #default>
              <div class="navigation__dropdown dropdown -rightAligned">
                <ul class="dropdown__list">
                  <li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
                  <li class="dropdown__item"><a class="dropdown__link" href=""><span>Consectetur asperiores</span></a></li>
                  <li class="dropdown__item"><a class="dropdown__link" href=""><span>Amet porro.</span></a></li>
                  <li class="dropdown__item"><a class="dropdown__link" href=""><span>Ipsum dolorem</span></a></li>
                </ul>
              </div>
            </template>
          </p-openable>
        </li>
      </ul>
    </nav>
    </div>
  </div>
</header>
<div style="min-height: 180vh" aria-hidden="true"></div>
index.scss
@use '@/core' as *;

/**
 * Drives `-fixed` compact ↔ expanded chrome in sync (one transition, no layout fights).
 */
@property --siteheader-compact {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

/**
 * `v-scroll-header` toggles `-active` on `-fixed` and `-hidden` on `-hideOnScroll`.
 * Variant modifiers stay on the block; JS only toggles state classes.
 */

.siteHeader {
  $b: &;

  --gap: var(--root-gap, 16px);
  --siteHeader-chrome-duration: 320ms;
  --siteHeader-chrome-ease: cubic-bezier(0.33, 1, 0.68, 1);
  --siteHeader-logo-max-height: calc(301px * 133 / 300);
  --siteHeader-primaryNav-row-height: calc(3rem + 1rem * var(--root-line-height, 1.5));

  background-color: transparent;
  box-shadow: none;

  /**
   * Expanded bar only. Optional `--siteHeader-min-height-when-fixed` sets a floor
   * when compact if a project needs it (default: content height only).
   */
  --siteHeader-min-height: 12rem;

  min-height: var(--siteHeader-min-height);
  padding-block: var(--gap);
  padding-inline: 0;
  position: relative;
  transition:
    background-color var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease),
    box-shadow var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease),
    min-height var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease),
    padding-block var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease);

  &__content {
    display: grid;
    grid: 'logo search mobile-nav' auto / auto 1fr auto;
    align-items: center;
    gap: 0 var(--gap);
    position: relative;
  }

  &__logo {
    align-self: start;
    color: var(--accent-color);
    grid-area: logo;
    max-width: 301px;
    padding: 0.5rem 0;
    width: 100%;

    svg {
      display: block;
      height: auto;
      width: 100%;
    }
  }

  &__utilityNav {
    display: none;
    grid-area: utility-nav;
    font-size: 0.75rem;
    justify-self: end;
    gap: 1.5rem;
  }

  &__primaryNav {
    display: none;
    grid-area: primary-nav;
    justify-self: end;
  }

  &__search {
    justify-self: end;
    width: #{fluid-rems(8, 8, 16)};
  }

  @include at(md) {
    &__content {
      grid:
        'logo utility-nav search' auto
        'primary-nav primary-nav primary-nav' auto
        / auto 1fr auto;
    }

    &__primaryNav,
    &__utilityNav {
      display: grid;
      justify-self: end;
    }

    &__mobileNav {
      display: none;
    }
  }

  @include at(lg) {
    &__content {
      grid:
        'logo utility-nav search' auto
        'logo spacer spacer' auto
        'logo primary-nav primary-nav' auto
        / auto 1fr auto;
    }
  }

  /**
   * Sticky + `--siteheader-compact` keeps layout in flow and animates all chrome
   * from one value so nav, logo, and bar height move together.
   */
  &.-fixed {
    --siteheader-compact: 0;

    background-color: color-mix(in srgb, var(--root-background-color, #ffffff) calc(var(--siteheader-compact) * 100%), transparent);
    min-height: calc((1 - var(--siteheader-compact)) * var(--siteHeader-min-height) + var(--siteheader-compact) * var(--siteHeader-min-height-when-fixed, 5.5rem));
    padding-block: var(--gap);
    position: sticky;
    top: 0;
    transition:
      --siteheader-compact var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease),
      box-shadow var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease);
    z-index: 200;

    #{$b}__content {
      align-items: start;
    }

    #{$b}__logo {
      align-self: end;
      padding: 0;

      svg {
        max-height: calc(var(--siteHeader-primaryNav-row-height) + (var(--siteHeader-logo-max-height) - var(--siteHeader-primaryNav-row-height)) * (1 - var(--siteheader-compact)));
      }
    }

    #{$b}__search {
      font-size: calc(0.94rem + 0.06rem * (1 - var(--siteheader-compact)));
    }

    #{$b}__primaryNav {
      .navigation__link,
      .navigation__button {
        font-size: calc(0.9rem + 0.1rem * (1 - var(--siteheader-compact)));
      }

      .navigation__item:has(.navigation__dropdown) .navigation__button {
        font-size: 1rem;
      }
    }

    #{$b}__mobileNav .mobileNavigation__menu {
      font-size: calc(0.9rem + 0.1rem * (1 - var(--siteheader-compact)));
    }
  }

  &.-fixed.-active {
    --siteheader-compact: 1;

    @include at(md) {
      box-shadow: 0 0.125rem 0.75rem rgb(0 0 0 / 0.06);
    }
  }

  /**
   * Full-size pinned bar; `-hidden` slides off on scroll down.
   */
  &.-hideOnScroll {
    background-color: var(--root-background-color, #ffffff);
    inset-inline: 0;
    min-height: unset;
    position: fixed;
    top: 0;
    transition: transform var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease);
    width: 100%;
    z-index: 200;

    &.-hidden {
      pointer-events: none;
      transform: translateY(-100%);
    }
  }

  /** Reserves space while pinned headers are visible (sibling follows header). */
  & + &__spacer {
    min-height: 0;
    pointer-events: none;
  }

  &.-hideOnScroll:not(.-hidden) + &__spacer {
    min-height: 9rem;
    transition: min-height var(--siteHeader-chrome-duration) var(--siteHeader-chrome-ease);
  }

  &.-hideOnScroll.-hidden + &__spacer {
    min-height: 0;
  }
}
POpenable.vue
<script>
const openableGroups = {}
</script>

<script setup>
import { ref, nextTick, useTemplateRef } from 'vue'

import focusableElements from '../composables/FocusableElements.js'

const { closeOnBlur, refocus, label, name, openOnHover, hoverOrClick } = defineProps({
  closeOnBlur: { type: Boolean, default: true },
  refocus: { type: Boolean, default: true },
  label: { type: String, default: '' },
  name: { type: String, default: 'global' },
  openOnHover: { type: Boolean, default: false },
  hoverOrClick: { type: Boolean, default: false },
})
const emit = defineEmits(['open', 'close'])
const button = useTemplateRef('button')
const openable = useTemplateRef('openable')
const open = ref(false)

const targetOutside = evt => {
  if (openable.value && !openable.value.contains(evt.target)) {
    toggle()
  }
}

const pressEscape = evt => {
  if (evt.key === 'Escape') {
    evt.stopPropagation()
    toggle()
  }
}

const updateGroup = open => {
  if (!name) {
    return
  }

  if (!(name in openableGroups)) {
    openableGroups[name] = new Set()
  }

  if (open) {
    openableGroups[name].forEach(t => t())
    openableGroups[name].add(toggle)
  } else {
    openableGroups[name].delete(toggle)
  }
}

const toggle = (evt, { noFocus = false } = {}) => {
  evt?.stopPropagation()
  emit(open.value ? 'close' : 'open')
  open.value = !open.value
  updateGroup(open.value)
  nextTick(() => {
    if (open.value) {
      if (refocus && !noFocus) {
        focusableElements(openable)?.[0].focus()
      }

      document.documentElement.addEventListener('click', targetOutside)
      openable.value.addEventListener('keydown', pressEscape)

      if (closeOnBlur) {
        document.documentElement.addEventListener('focusin', targetOutside)
      }
    } else {
      document.documentElement.removeEventListener('click', targetOutside)
      openable.value.removeEventListener('keydown', pressEscape)

      if (closeOnBlur) {
        document.documentElement.removeEventListener('focusin', targetOutside)
      }

      if (refocus && !noFocus) {
        focusableElements(button)?.[0].focus()
      }
    }
  })
}

let timeout = null

const mouseover = () => {
  if (timeout) {
    clearTimeout(timeout)
  }

  timeout = setTimeout(() => {
    if (!open.value) {
      toggle(undefined, { noFocus: true })
    }
  }, 450)
}

const mouseout = () => {
  if (timeout) {
    clearTimeout(timeout)
  }

  timeout = setTimeout(() => {
    if (open.value) {
      toggle(undefined, { noFocus: true })
    }
  }, 450)
}

const keypress = evt => {
  if (evt.key == ' ' || evt.key == 'Enter') {
    toggle()
  }
}

let bindings = { click: toggle }
if (openOnHover) {
  bindings = { mouseover, mouseout, keypress }
}
if (hoverOrClick) {
  bindings = { click: toggle, mouseover, mouseout }
}
</script>

<template>
  <button v-bind="$attrs" ref="button" v-on="bindings">
    <slot name="toggle" v-bind="bindings">
      {{ label }}
    </slot>
  </button>
  <div ref="openable" v-on="openOnHover || hoverOrClick ? { mouseover, mouseout } : {}">
    <slot v-if="open" v-bind="bindings" />
  </div>
</template>
vDirectionals.js
import focusableElements from '../composables/FocusableElements.js'

/**
 * Default key filters. These should be functions that 'filter out' what
 * elements should be considered when a key is hit.
 */
const KEY_BINDINGS = {
  ArrowRight: elements => elements.filter(({ x, top, bottom }) => x > 0 && top < 0 && bottom > 0),
  ArrowLeft: elements => elements.filter(({ x, top, bottom }) => x < 0 && top < 0 && bottom > 0),
  ArrowDown: elements => elements.filter(({ y, left, right }) => y > 0 && left < 0 && right > 0),
  ArrowUp: elements => elements.filter(({ y, left, right }) => y < 0 && left < 0 && right > 0),
  Home: elements => elements.length && elements.slice(0, 1),
  End: elements => elements.length && elements.slice(-1),
}

/**
 * Retrieves where the element is drawn on screen (client rects).
 * Adds in x and y for the center of the element.
 */
const getElementRects = el => {
  if (!el) {
    console.error('Unable to determine location of element', el)
    return null
  }

  const rects = el.getClientRects()[0]

  if (!rects) {
    return getElementRects(el.parentElement)
  }

  return {
    bottom: rects.bottom,
    height: rects.height,
    left: rects.left,
    right: rects.right,
    top: rects.top,
    width: rects.width,
    x: rects.left + rects.width / 2,
    y: rects.top + rects.height / 2,
  }
}

/**
 * Calls getElementRects for every element in nodeList, and adjust the
 * values to be relative to origin for simpler follow up math. Also
 * calculates the distance between the two elements' centers.
 */
const augmentElementRects = (nodeList, origin) => {
  const elements = []
  origin = getElementRects(origin)

  nodeList.forEach(el => {
    const rects = getElementRects(el)
    if (rects === null) {
      return
    }

    rects.bottom -= origin.y
    rects.left -= origin.x
    rects.right -= origin.x
    rects.top -= origin.y
    rects.x -= origin.x
    rects.y -= origin.y

    const distance = Math.sqrt(rects.x * rects.x + rects.y * rects.y)

    elements.push({ el, ...rects, distance })
  })

  return elements
}

const findTarget = (el, key, root) => {
  const elements = augmentElementRects(focusableElements(root), el)
  if (key in KEY_BINDINGS) {
    const { el } = KEY_BINDINGS[key](elements).reduce(
      (closest, el) => {
        return el.distance < closest.distance ? el : closest
      },
      { distance: Infinity },
    )

    return el
  }
  return null
}

export default {
  mounted(element) {
    const handler = evt => {
      const target = findTarget(evt.target, evt.key, element)

      if (target) {
        evt.preventDefault()
        evt.stopPropagation()
        target.focus()
      }
    }
    element.addEventListener('keydown', handler)
  },
}
vScrolllock.js
export default {
  mounted() {
    document.documentElement.style.overflow = 'hidden'
  },
  unmounted() {
    document.documentElement.style.overflow = ''
  },
}