> ## Documentation Index
> Fetch the complete documentation index at: /llms.txt
> Use this file to discover all available pages before exploring further.

# Pan Stations

> A lateral camera move across a continuous row of labeled feature stations, with a steady dwell at each stop before the next pan.

# Pan Stations

A lateral camera move across a continuous row of labeled feature stations, with a steady dwell at each stop before the next pan.

**Pan Stations**

```html pan-stations.html theme={null}
<!doctype html>
<!--
  pan-stations -- HyperFrames video primitive (camera / holdable / exhibit)

  Concept: the camera pans laterally across a wide row of labeled feature
  stations, settling on each card before moving to the next. The exhibit
  reading is primary here. The agitate reading is cross-shelved because the
  same camera move can scan broken tools instead of polished features.

  Compiled-from evidence: candidates card "pan-stations" plus the ship doc:
  "one mechanic, two readings: panning across broken tools agitates, panning
  across features exhibits. Re-filed by the adversary pass precisely because
  it serves both shelves; the pause cadence is the frozen law." Evidence tags:
  SIM fixture, CORPUS blueprint (re-filed per adversary; cross-shelved).

  Use when: a sequence of tools, steps, screens, or features should read as
  one continuous environment that the camera inspects in order.

  Variables (declared in data-composition-variables below):
    - stationCount (number, default 4, range 3-6): number of cards in the row.
    - labels (CSV string, default "Capture,Edit,Render,Share"): visible station
      labels, padded with "Station N" when entries are missing.
    - dwell (seconds, default 0.6, range 0.2-2): steady hold at each station.

  Envelope (fixed IN/OUT, elastic HOLD only, never gsap.timeScale()):
    IN_BASE  = 0.45s  the first station settles into view
    HOLD     = max(0, D - (IN + OUT)); complete dwell and pan segments are
               scheduled while they fit, then the last reached station holds
    OUT_BASE = 0.40s  the current station fades out
    If D < IN_BASE + OUT_BASE, IN and OUT scale down together so HOLD is zero.

  Sync points: none. Station arrivals live in elastic HOLD and are deliberately
  not declared as sync points. Dwell gaps contain no eased tween.

  Sound cue: none. A host can layer its own station arrival foley if needed.

  Mount contract: this is a mountable sub-composition. The runtime clones only
  <template> contents, so every real style, node, and script lives inside the
  template. #root has no data-width or data-height and fills the host box. It
  is styled by ID because composited CSS scoping can stop a root class selector
  from matching. Variables come only from getVariables() after mount, because
  document.documentElement belongs to the host document at that point.
-->
<html
  lang="en"
  data-composition-variables='[
    { "id": "stationCount", "type": "number", "role": "layout", "label": "Station count", "description": "Number of feature stations in the row.", "default": 4, "min": 3, "max": 6, "step": 1 },
    { "id": "labels", "type": "string", "role": "content", "label": "Station labels", "description": "Comma-separated labels shown on the station cards.", "default": "Capture,Edit,Render,Share" },
    { "id": "dwell", "type": "number", "role": "timing", "label": "Dwell", "description": "Seconds the camera holds at each station before panning.", "default": 0.6, "min": 0.2, "max": 2, "step": 0.1, "unit": "s" }
  ]'
>
  <head>
    <meta charset="UTF-8" />
    <title>Pan Stations</title>
    <!-- Metadata only. Everything outside <template> is discarded on mount. -->
  </head>
  <body>
    <template>
      <div
        id="root"
        data-composition-id="pan-stations"
        data-start="0"
        data-duration="4"
        data-fps="30"
      >
        <style>
          *,
          *::before,
          *::after {
            box-sizing: border-box;
          }

          #root {
            --ease-standard: cubic-bezier(0.215, 0.61, 0.355, 1);
            --ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19);
            --ease-drift: cubic-bezier(0.445, 0.05, 0.55, 0.95);
            --ps-station-width: 78cqw;
            --ps-station-gap: var(--space-4, 6cqw);
            position: absolute;
            inset: 0;
            container-type: size;
            isolation: isolate;
            overflow: hidden;
            background: var(--bg, #0b1120);
            color: var(--fg, #f8fafc);
            font-family: var(--font-body, Inter, system-ui, sans-serif);
          }

          .ps-clip {
            position: relative;
            width: 100%;
            height: 100%;
            overflow: hidden;
            opacity: 0;
          }

          .ps-rail {
            position: absolute;
            left: 0;
            right: 0;
            top: 50%;
            height: 0.18cqh;
            background: var(--border, #334155);
            opacity: 0.7;
          }

          .ps-strip {
            --ps-pan-index: 0;
            position: absolute;
            top: 18cqh;
            left: 0;
            display: flex;
            align-items: stretch;
            gap: var(--ps-station-gap);
            width: max-content;
            padding: 0 11cqw;
            transform: translateX(
              calc(var(--ps-pan-index) * -1 * (var(--ps-station-width) + var(--ps-station-gap)))
            );
            will-change: transform;
          }

          .ps-station {
            position: relative;
            flex: 0 0 var(--ps-station-width);
            height: 64cqh;
            display: grid;
            grid-template-rows: auto 1fr auto;
            gap: var(--space-3, 3cqh);
            padding: var(--space-5, 5cqw);
            overflow: hidden;
            border: 0.16cqw solid var(--border, #334155);
            border-radius: 3.2cqw;
            background: var(--surface, #1e293b);
            box-shadow: 0 2.2cqh 6cqw color-mix(in srgb, var(--bg, #0b1120) 72%, transparent);
          }

          .ps-station::before {
            content: "";
            position: absolute;
            inset: 0;
            background: radial-gradient(
              circle at 82% 18%,
              color-mix(in srgb, var(--brand, #22c55e) 24%, transparent),
              transparent 42%
            );
          }

          .ps-kicker,
          .ps-label,
          .ps-screen {
            position: relative;
            z-index: 1;
          }

          .ps-kicker {
            display: flex;
            align-items: center;
            justify-content: space-between;
            color: var(--muted, #94a3b8);
            font-size: 1.8cqw;
            font-weight: 700;
            letter-spacing: 0.16em;
            text-transform: uppercase;
          }

          .ps-index {
            color: var(--accent, #38bdf8);
            font-variant-numeric: tabular-nums;
          }

          .ps-screen {
            display: grid;
            grid-template-columns: 0.56fr 1fr;
            gap: var(--space-3, 3cqw);
            min-height: 0;
          }

          .ps-tool,
          .ps-stack {
            border: 0.13cqw solid var(--border, #334155);
            border-radius: 2cqw;
            background: color-mix(in srgb, var(--bg, #0b1120) 56%, var(--surface, #1e293b));
          }

          .ps-tool {
            display: grid;
            place-items: center;
          }

          .ps-glyph {
            width: 8.5cqw;
            aspect-ratio: 1;
            display: grid;
            place-items: center;
            border-radius: 2.2cqw;
            background: var(--brand, #22c55e);
            color: var(--bg, #0b1120);
            font-family: var(--font-display, Inter, system-ui, sans-serif);
            font-size: 3.2cqw;
            font-weight: 850;
          }

          .ps-stack {
            display: grid;
            align-content: center;
            gap: var(--space-2, 1.8cqh);
            padding: var(--space-3, 3cqw);
          }

          .ps-line {
            height: 1.15cqh;
            border-radius: 50%;
            background: var(--border, #334155);
          }

          .ps-line:nth-child(1) {
            width: 82%;
            background: var(--accent, #38bdf8);
          }

          .ps-line:nth-child(2) {
            width: 100%;
          }

          .ps-line:nth-child(3) {
            width: 64%;
          }

          .ps-label {
            max-width: 100%;
            overflow-wrap: anywhere;
            color: var(--fg, #f8fafc);
            font-family: var(--font-display, Inter, system-ui, sans-serif);
            font-size: 5.8cqw;
            font-weight: 760;
            letter-spacing: -0.045em;
            line-height: 0.98;
          }
        </style>

        <div
          id="pan-stations-clip"
          class="ps-clip clip"
          data-start="0"
          data-duration="4"
          data-track-index="0"
        >
          <div class="ps-rail"></div>
          <div class="ps-strip" aria-label="Feature stations"></div>
        </div>

        <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
        <script>
          (function () {
            "use strict";

            var root = document.getElementById("root");
            // Literal by contract: the mounted root loses data-composition-id.
            var compositionId = "pan-stations";
            var clip = root.querySelector(".ps-clip");
            var strip = root.querySelector(".ps-strip");
            var vars =
              window.__hyperframes && window.__hyperframes.getVariables
                ? window.__hyperframes.getVariables()
                : {};

            // INVARIANT: stationCount is always an integer in the declared 3-6 range.
            var rawCount = Number(vars.stationCount);
            var stationCount = Number.isFinite(rawCount) ? Math.round(rawCount) : 4;
            stationCount = Math.max(3, Math.min(6, stationCount));

            // INVARIANT: dwell is finite and positive, then clamps to 0.2-2 seconds.
            var rawDwell = Number(vars.dwell);
            var dwell = Number.isFinite(rawDwell) ? rawDwell : 0.6;
            dwell = Math.max(0.2, Math.min(2, dwell));

            var labelsSource =
              vars.labels == null ? "Capture,Edit,Render,Share" : String(vars.labels);
            var parsedLabels = labelsSource.split(",").map(function (label) {
              return label.trim();
            });

            // Station creation is the only owner of row content and fallback labels.
            for (var i = 0; i < stationCount; i += 1) {
              var label = parsedLabels[i] || "Station " + (i + 1);

              var station = document.createElement("article");
              station.className = "ps-station";
              station.setAttribute("aria-label", label);

              var kicker = document.createElement("div");
              kicker.className = "ps-kicker";
              kicker.innerHTML = '<span>Feature station</span><span class="ps-index"></span>';
              kicker.querySelector(".ps-index").textContent = String(i + 1).padStart(2, "0");

              var screen = document.createElement("div");
              screen.className = "ps-screen";
              screen.innerHTML =
                '<div class="ps-tool"><span class="ps-glyph"></span></div>' +
                '<div class="ps-stack"><span class="ps-line"></span><span class="ps-line"></span><span class="ps-line"></span></div>';
              screen.querySelector(".ps-glyph").textContent = label.charAt(0).toUpperCase();

              var title = document.createElement("div");
              title.className = "ps-label";
              title.textContent = label;

              station.appendChild(kicker);
              station.appendChild(screen);
              station.appendChild(title);
              strip.appendChild(station);
            }

            var stations = Array.from(strip.querySelectorAll(".ps-station"));

            // RETIME RANGE: fixed IN/OUT, elastic HOLD, no global time scaling.
            var IN_BASE = 0.45;
            var OUT_BASE = 0.4;
            var PAN_DURATION = 0.25;
            var durationValue = parseFloat(root.dataset.duration || "4");
            var duration = Number.isFinite(durationValue) ? Math.max(0.001, durationValue) : 4;
            var totalBase = IN_BASE + OUT_BASE;
            var envelopeScale = duration < totalBase ? duration / totalBase : 1;
            var IN = IN_BASE * envelopeScale;
            var OUT = OUT_BASE * envelopeScale;
            var HOLD = Math.max(0, duration - IN - OUT);
            var HOLD_START = IN;
            var OUT_START = IN + HOLD;

            // Explicit t=0 state for every animated endpoint.
            gsap.set(clip, { opacity: 0 });
            gsap.set(strip, { "--ps-pan-index": 0 });

            var tl = gsap.timeline({ paused: true });
            tl.to(clip, { opacity: 1, duration: IN, ease: "power2.out" }, 0);

            // HOLD: empty time is the steady dwell. Only complete pans are added.
            var cursor = HOLD_START;
            for (var stationIndex = 0; stationIndex < stations.length - 1; stationIndex += 1) {
              cursor += dwell;
              if (cursor + PAN_DURATION > OUT_START) break;
              tl.to(
                strip,
                {
                  "--ps-pan-index": stationIndex + 1,
                  duration: PAN_DURATION,
                  ease: "sine.inOut",
                },
                cursor,
              );
              cursor += PAN_DURATION;
            }

            tl.to(clip, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
            tl.seek(0);

            window.__timelines = window.__timelines || {};
            window.__timelines[compositionId] = tl;
          })();
        </script>
      </div>
    </template>
  </body>
</html>
```

Copy npx hyperframes add pan-stations to the clipboard

That writes one file: `compositions/components/pan-stations.html`.

## Paste it into your composition

Open `compositions/components/pan-stations.html` and copy what is inside into your own composition.

A component has no size or duration of its own. It takes both from the composition
you paste it into.

## Variables

Every one of these has a default, so the piece works untouched. Set the ones you
want to change on the element:

| Variable | Default | Accepts | What it does |
| - | - | - | - |
| `stationCount` | `4` | 3 to 6, step 1 | Number of feature stations in the row. |
| `labels` | `Capture,Edit,Render,Share` | string | Comma-separated labels shown on the station cards. |
| `dwell` | `0.6` | 0.2s to 2s, step 0.1s | Seconds the camera holds at each station before panning. |

Set them with `data-variable-values` on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:

```html wrap theme={null}
<div
  data-composition-id="pan-stations"
  data-composition-src="compositions/components/pan-stations.html"
  data-variable-values='{"stationCount":4,"labels":"Capture,Edit,Render,Share","dwell":0.6}'
></div>
```

Tagged `camera` `pan` `stations` `features` `exhibit` `agitate` `cross-shelved`.

## Related topics

* [Browse the complete Catalog](/catalog.md)
* [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks.md)
* [Put blocks together into one video](/concepts/compositions.md)


## Related topics

- [Whip Pan](/catalog/blocks/whip-pan.md)
- [Whip Pan Cut](/catalog/components/whip-pan-cut.md)
- [Panel Reveal](/catalog/components/panel-reveal.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.