Navigation blueprint

Map detail sheet

A bottom sheet rises far enough to feel intentional while leaving the route behind it legible.

Playground

Playhead 0ms / 420ms

Build it

Choose a target and adapt the source to your interface.

298 lines
index.html
<div class="map-demo">
  <svg
    class="map-art"
    viewBox="0 0 360 240"
    preserveAspectRatio="xMidYMid slice"
    aria-hidden="true"
  >
    <path
      class="map-streets"
      d="M-20 48 380 142M-20 164 380 258M46-20 4 260M163-20 109 260M307-20 254 260"
    />
    <path class="map-water" d="M-20 202C70 206 98 70 199 97S295 70 380 8" />
    <path class="map-route" d="M-20 177C65 184 96 45 203 73S296 46 374-8" />
  </svg>
  <p class="map-caption">Canalside · Walking route</p>
  <button
    class="map-trigger"
    type="button"
    aria-expanded="false"
    aria-controls="place-details"
  >
    <svg viewBox="0 0 20 20" aria-hidden="true">
      <path d="M16 8c0 4-6 9-6 9S4 12 4 8a6 6 0 1 1 12 0Z" />
      <circle cx="10" cy="8" r="2" /></svg
    >Canal walk
  </button>
  <section
    class="detail-sheet"
    id="place-details"
    aria-label="Place details"
    aria-hidden="true"
    inert
    hidden
  >
    <div class="sheet-grip" aria-hidden="true"></div>
    <div class="sheet-heading">
      <h2>Along the canal</h2>
      <button class="sheet-close" type="button" aria-label="Close details">
        <svg viewBox="0 0 20 20" aria-hidden="true">
          <path d="m5 5 10 10M15 5 5 15" /></svg
        ><span class="map-sr-only">Close details</span>
      </button>
    </div>
    <p>A quiet walk beside the water.</p>
    <div class="sheet-meta">
      <span><strong>1.8 km</strong> loop</span
      ><span><strong>24 min</strong> walk</span>
    </div>
  </section>
</div>

<script>
  const root = document.querySelector('.map-demo');
  const trigger = root.querySelector('.map-trigger');
  const sheet = root.querySelector('.detail-sheet');
  const closeButton = sheet.querySelector('.sheet-close');
  const reduced = matchMedia('(prefers-reduced-motion:reduce)');
  let animation;
  function toggle(open) {
    const from = sheet.hidden
      ? 'translateY(100%)'
      : getComputedStyle(sheet).transform;
    animation?.cancel();
    animation = null;
    trigger.setAttribute('aria-expanded', String(open));
    sheet.hidden = false;
    sheet.inert = !open;
    sheet.setAttribute('aria-hidden', String(!open));
    if (open) closeButton.focus({ preventScroll: true });
    else trigger.focus({ preventScroll: true });
    if (reduced.matches) {
      sheet.hidden = !open;
      return;
    }
    const current = sheet.animate(
      [
        { transform: from },
        { transform: open ? 'translateY(0)' : 'translateY(100%)' },
      ],
      { duration: 420, easing: 'cubic-bezier(.22,1,.36,1)', fill: 'both' },
    );
    animation = current;
    current.finished
      .then(() => {
        if (animation !== current) return;
        sheet.hidden = !open;
        current.cancel();
        animation = null;
      })
      .catch(() => {});
  }
  trigger.addEventListener('click', () =>
    toggle(trigger.getAttribute('aria-expanded') !== 'true'),
  );
  closeButton.addEventListener('click', () => toggle(false));
  root.addEventListener('keydown', (event) => {
    if (
      event.key === 'Escape' &&
      trigger.getAttribute('aria-expanded') === 'true'
    ) {
      event.preventDefault();
      toggle(false);
    }
  });
  reduced.addEventListener('change', () => {
    if (!reduced.matches) return;
    animation?.cancel();
    animation = null;
    sheet.hidden = trigger.getAttribute('aria-expanded') !== 'true';
  });
</script>

<style>
  .map-demo {
    --map-bg: var(--stage-bg, #f8f9fb);
    --map-ink: var(--stage-ink, #252b34);
    --map-muted: var(--stage-muted, #667080);
    --map-line: var(--stage-grid, #dce1e8);
    --map-accent: var(--stage-link, #175bc5);
    position: relative;
    width: 360px;
    max-width: 100%;
    height: 240px;
    overflow: hidden;
    border: 1px solid var(--map-line);
    border-radius: 12px;
    background: var(--map-bg);
    color: var(--map-ink);
    font:
      14px/1.5 system-ui,
      sans-serif;
    isolation: isolate;
  }
  .map-art {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
  }
  .map-streets {
    fill: none;
    stroke: var(--map-line);
    stroke-width: 10;
  }
  .map-water {
    fill: none;
    stroke: color-mix(in srgb, var(--map-accent) 14%, var(--map-bg));
    stroke-width: 38;
  }
  .map-route {
    fill: none;
    stroke: var(--map-accent);
    stroke-width: 2;
    stroke-dasharray: 3 5;
    stroke-linecap: round;
  }
  .map-caption {
    position: absolute;
    left: 16px;
    bottom: 14px;
    margin: 0;
    font-size: 11px;
    color: var(--map-muted);
  }
  .map-trigger {
    position: absolute;
    top: 42px;
    left: 154px;
    display: flex;
    align-items: center;
    gap: 7px;
    min-height: 44px;
    padding: 0 12px;
    border: 1px solid var(--map-line);
    border-radius: 24px;
    background: var(--map-bg);
    color: var(--map-ink);
    box-shadow: 0 3px 10px #0000000d;
    font: 500 12px system-ui;
    cursor: pointer;
  }
  .map-trigger svg {
    width: 15px;
    height: 15px;
    color: var(--map-accent);
    fill: none;
    stroke: currentColor;
    stroke-width: 1.7;
  }
  .map-trigger:hover {
    border-color: var(--map-accent);
  }
  .map-demo button:focus-visible {
    outline: 2px solid var(--map-accent);
    outline-offset: 3px;
  }
  .detail-sheet {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    padding: 12px 18px 18px;
    border-top: 1px solid var(--map-line);
    border-radius: 16px 16px 0 0;
    background: color-mix(in srgb, var(--map-bg), var(--map-ink) 3%);
    box-shadow: 0 -6px 24px #0000000a;
  }
  .detail-sheet[hidden] {
    display: none;
  }
  .sheet-grip {
    width: 28px;
    height: 3px;
    margin: 0 auto 12px;
    border-radius: 4px;
    background: var(--map-line);
  }
  .sheet-heading {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
  }
  .detail-sheet h2 {
    margin: 0;
    font-size: 19px;
    font-weight: 600;
    letter-spacing: -0.025em;
    line-height: 1.3;
  }
  .sheet-close {
    display: grid;
    place-items: center;
    flex: none;
    width: 44px;
    height: 44px;
    margin: -8px -8px -8px 0;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: transparent;
    color: var(--map-muted);
    cursor: pointer;
  }
  .sheet-close:hover {
    background: color-mix(in srgb, var(--map-ink) 7%, transparent);
    color: var(--map-ink);
  }
  .sheet-close svg {
    width: 18px;
    height: 18px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.5;
    stroke-linecap: round;
  }
  .detail-sheet p {
    margin: 5px 0 12px;
    font-size: 12px;
    color: var(--map-muted);
  }
  .sheet-meta {
    display: flex;
    gap: 16px;
    font-size: 11px;
    color: var(--map-muted);
  }
  .sheet-meta strong {
    color: var(--map-ink);
    font-weight: 500;
  }
  .map-sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }
  @media (prefers-color-scheme: dark) {
    .map-demo {
      --map-bg: var(--stage-bg, #101317);
      --map-ink: var(--stage-ink, #eef0f3);
      --map-muted: var(--stage-muted, #a8b1be);
      --map-line: var(--stage-grid, #353d49);
      --map-accent: var(--stage-link, #94bfff);
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .detail-sheet {
      animation: none;
      transition: none;
    }
  }
</style>

Animation breakdown

Frame phases, motion intent, implementation constraints and accessible alternatives.

420ms
01

Rest: map stays visible.

02

0–300ms: sheet rises from the bottom.

03

300–420ms: it settles with the route still legible. Closing reverses the slide over 420ms before the sheet is hidden.

Motion intent

A bottom sheet rises far enough to feel intentional while leaving the route behind it legible.

This duration leaves enough time to read the state change while keeping the interface responsive.

The fast acceleration and long ease-out make the state feel responsive while giving the final pixels time to settle.

Motion specification

Trigger
A map pin is selected.
Timing
420ms
Easing
cubic-bezier(.22, 1, .36, 1)

Animated properties

translaterotateshadowz-index state

Implementation notes

  • Keep map and sheet open aligned to the same visual anchor.
  • Drive the transition from semantic state, not a timer, when the trigger is interactive.
  • Use translate, rotate, shadow, z-index state and avoid layout work inside the animation loop.

Reduced motion

Swap to the final state instantly, preserving colour, label, and accessible state.

  • Use a minimum 44 by 44 pixel interactive target.
  • Expose the map and sheet open states with aria-pressed, aria-expanded, or an updated accessible label.
  • Keep visible keyboard focus independent from the animation.

Avoid

  • Do not add bounce unless a real object pickup or spring settle is part of the interaction.
  • Do not hide the final state from assistive technology.
  • Do not make the user wait for animation before the underlying state updates.

Related blueprints

Browse all blueprints