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

# Touch Indicator

> A translucent contact-circle gesture actor for mobile UI scenes: it touches the glass, causes a same-frame response, and lifts. Tap or swipe, picked by variable.

export const InstallCommand = ({command}) => {
  const [copied, setCopied] = React.useState(false);
  const copy = async () => {
    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(command);
      } else {
        const previous = document.activeElement;
        const scratch = document.createElement("textarea");
        scratch.value = command;
        scratch.setAttribute("readonly", "");
        scratch.style.position = "fixed";
        scratch.style.opacity = "0";
        document.body.appendChild(scratch);
        scratch.select();
        document.execCommand("copy");
        document.body.removeChild(scratch);
        previous?.focus?.();
      }
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch {}
  };
  return <div className="hf-install-command not-prose my-4 flex items-stretch overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
      <code className="flex-1 overflow-x-auto whitespace-nowrap border-r border-zinc-200 px-4 py-3 font-mono text-sm text-zinc-800 dark:border-zinc-800 dark:text-zinc-100">
        {command}
      </code>
      <button type="button" onClick={copy} data-copied={copied ? "true" : "false"} aria-label={`Copy ${command} to the clipboard`} className="hf-install-copy">
        <svg className="hf-install-copy-clipboard" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" />
          <path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" />
        </svg>
        <svg className="hf-install-copy-check" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M2.75 9.5L6.5 13.25L15.25 4.5" />
        </svg>
      </button>
      <span className="hf-install-copy-status" role="status" aria-live="polite">
        {copied ? "Copied" : ""}
      </span>
    </div>;
};

<iframe className="w-full aspect-video rounded-xl border-0 bg-zinc-100 dark:bg-zinc-800" title="touch-indicator preview" loading="lazy" srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}hyperframes-player{display:block;width:100%;height:100%}</style><script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@0.7/dist/hyperframes-player.global.js"><\/script></head><body><script>fetch("/public/catalog/components/touch-indicator.json").then(function(r){return r.json()}).then(function(d){var p=document.createElement("hyperframes-player");p.setAttribute("srcdoc",d.html);p.setAttribute("controls","");p.setAttribute("autoplay","");p.setAttribute("loop","");p.setAttribute("muted","");p.setAttribute("poster","https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/touch-indicator.png");document.body.appendChild(p)});<\/script></body></html>`} />

## Install

<InstallCommand command="npx hyperframes add touch-indicator" />

That writes one file: `compositions/components/touch-indicator.html`.

## Paste it into your composition

Open `compositions/components/touch-indicator.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 |
| ---------- | ------- | --------------- | ------------ |
| `gesture`  | `tap`   | `tap`, `swipe`  |              |
| `polarity` | `light` | `light`, `dark` |              |
| `targetX`  | `50`    | 0% to 100%      |              |
| `targetY`  | `55`    | 0% to 100%      |              |

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="touch-indicator"
  data-composition-src="compositions/components/touch-indicator.html"
  data-variable-values='{"gesture":"tap","polarity":"light","targetX":50,"targetY":55}'
></div>
```

## Source

<Accordion title={`touch-indicator.html`}>
  ```html theme={null}
  <!doctype html>
  <!--
    touch-indicator -- HyperFrames video primitive (pointers / interaction / demonstrate)

    CONCEPT: a translucent contact-circle that represents a fingertip touching
    glass. It never hovers: every appearance resolves into a touch-down, every
    touch causes a same-frame response on its target, every gesture ends with
    a physical lift. One mechanic (tap or swipe, picked by a variable at
    setup), one file. This is the mobile counterpart to an oversized cursor,
    but it obeys touch physics, not pointer physics.

    MOUNTABLE SUB-COMPOSITION: this file is loaded by a host via
    data-composition-src, not pasted inline. The runtime only clones
    <template> contents (see skills/hyperframes-core/references/sub-compositions.md),
    so every style, the markup, and the script that builds and registers the
    timeline all live inside <template> below. The file is self-driving: it
    builds its own paused GSAP timeline and registers it under
    window.__timelines["touch-indicator"] on load, matching the root's own
    data-composition-id. A host mounts it with a matching
    data-composition-id="touch-indicator" data-composition-src="./touch-indicator.html".

    COMPILED FROM: hyperframes-corpus-data/mobile/gesture-actor-spec.md (the
    contact-never-hover law, contact-circle sizing, tap anatomy, swipe law,
    lift/exit law, same-frame target-reaction contract) with supporting
    constants cross-checked against gesture-physics-recipes.md. Evidence
    status: spec ready, no rendered corpus clips yet.

    USE WHEN: a mobile UI scene needs a visible tap or swipe to cause the next
    animation beat (button press, list scroll, card swipe, sheet reveal).
    Skip it for cursor/pointer UI (use a cursor primitive) and for gestures
    whose drawn path is itself the subject (use a trail primitive instead).

    VARIABLES (declared below via data-composition-variables):
      - gesture   enum "tap" | "swipe", default "tap"
      - polarity  enum "light" | "dark", default "light"
      - targetX   number 0-100 (percent of host width), default 50
      - targetY   number 0-100 (percent of host height), default 55

    ENVELOPE (seconds, 60fps frame-quantized, gesture = "tap"):
      IN   0.000 - 0.167  approach (0.083) + touch-down compress (0.083)
      HOLD 0.167 - 0.183  elastic dwell at compressed scale (stretch here only)
      OUT  0.183 - 0.467  release (0.167) + lift (0.117), lift starts the
                          instant release ends (0s gap, within the spec's
                          <=0.04s allowance)

    ENVELOPE (seconds, 60fps frame-quantized, gesture = "swipe"):
      IN   0.000 - 0.167  approach (0.083) + touch-down compress (0.083)
      HOLD 0.167 - 0.500  elastic travel: accelerate 41% / decelerate 59% of
                          the stretchable swipe duration (default 0.333)
      OUT  0.500 - 0.617  lift only (0.117); a swiping finger does not settle
                          back to rest scale, it leaves mid-motion

    SOUND CUES (fixed offsets, never inside the elastic HOLD):
      touch-down  IN + 0.083s   -> soft fingertip contact
      lift-off    tap:   OUT + 0.167s -> physical release tick
                  swipe: OUT + 0s     -> physical release tick (no bounce)

    BUILT-IN RESPONSE: the primitive has to read standalone in a slot with no
    host UI to react to it, so touch-down also drives its own contact-blur
    ripple (EDIT ZONE: .hf-touch-indicator__contact, unchanged from the
    original spec) plus a quiet radial pulse centered on the root
    (.hf-touch-indicator__pulse, new). Both are token-painted and low-opacity
    so they read as a same-frame acknowledgement, not a second effect
    competing with a host's own reaction wiring.
  -->
  <html
    lang="en"
    data-composition-variables='[
      {"id":"gesture","type":"enum","label":"Gesture","default":"tap","options":[{"value":"tap","label":"Tap"},{"value":"swipe","label":"Swipe"}]},
      {"id":"polarity","type":"enum","label":"Polarity","default":"light","options":[{"value":"light","label":"Light (on dark UI)"},{"value":"dark","label":"Dark (on light UI)"}]},
      {"id":"targetX","type":"number","label":"Target X","default":50,"min":0,"max":100,"unit":"%"},
      {"id":"targetY","type":"number","label":"Target Y","default":55,"min":0,"max":100,"unit":"%"}
    ]'
  >
    <head>
      <meta charset="UTF-8" />
      <title>Touch Indicator</title>
      <!-- Head is metadata for the source file only; the mount runtime clones
           only <template> contents and discards everything else, including
           this head. See sub-compositions.md, "Pitfall 1".

           The data-composition-variables declared on <html> above duplicates
           the same schema on the #root div inside <template> below (the
           "dual-carrier contract", core/src/runtime/compositionLoader.ts and
           packages/parsers/src/htmlParser.ts both read declared defaults from
           <html> only on the lazy external-load / CLI-metadata paths, while
           the render-time bundler reads the template's root div). Keep both
           copies in sync if the variable schema changes. -->
    </head>
    <body>
      <template>
        <style>
          /* INVARIANT: root fills the host's box, position-blind, elastic to
             whatever size a host slot gives it. No data-width/data-height on
             the root -- the host slot owns the size (see mount contract).
             Root is styled by #root, never a class (sub-compositions.md,
             Pitfall 3). */
          #root {
            position: absolute;
            inset: 0;
            container-type: inline-size;
            isolation: isolate;
            pointer-events: none;
          }

          .hf-touch-indicator__pulse {
            /* Root-level built-in response: a quiet radial flash centered on
               the touch point, so the gesture reads even with no host UI to
               react to it. Token-painted, low peak opacity -- see BUILT-IN
               RESPONSE above. */
            position: absolute;
            left: 0;
            top: 0;
            width: clamp(120px, 30cqw, 320px);
            height: clamp(120px, 30cqw, 320px);
            border-radius: 50%;
            background: radial-gradient(
              circle,
              color-mix(in srgb, var(--fg, #14181f) 30%, transparent) 0%,
              transparent 72%
            );
            opacity: 0;
            pointer-events: none;
            will-change: transform, opacity;
          }

          .hf-touch-indicator__actor {
            /* EDIT ZONE: sizing law from the spec, full-frame law (contact
               circle target 5cqw of the host box). RETIME RANGE: floor
               clamped to 46px (the spec's absolute floor, never smaller at
               delivery scale) since cqw now resolves against whatever box a
               host slot gives this mountable primitive, which can be much
               narrower than a full 1920-wide frame. */
            --hf-gesture-size: clamp(46px, 5cqw, 111px);
            position: absolute;
            left: 0;
            top: 0;
            width: var(--hf-gesture-size);
            height: var(--hf-gesture-size);
            opacity: 0;
            visibility: hidden;
            pointer-events: none;
            will-change: transform, opacity;
          }

          .hf-touch-indicator__core {
            position: absolute;
            inset: 0;
            box-sizing: border-box;
            /* INVARIANT: circle geometry, hardcoded 50%, not var(--radius) -
               that token is for rectangular UI corners, not this actor's
               fixed disc. */
            border-radius: 50%;
            background: color-mix(in srgb, var(--bg, #ffffff) 68%, transparent);
            border: 2px solid color-mix(in srgb, var(--fg, #14181f) 72%, transparent);
            box-shadow: 0 4px 12px color-mix(in srgb, var(--fg, #14181f) 22%, transparent);
            transform-origin: 50% 50%;
            will-change: transform;
          }

          .hf-touch-indicator__actor--dark .hf-touch-indicator__core {
            background: color-mix(in srgb, var(--fg, #14181f) 64%, transparent);
            border-color: color-mix(in srgb, var(--bg, #ffffff) 86%, transparent);
          }

          .hf-touch-indicator__contact {
            position: absolute;
            left: 50%;
            top: 54%;
            width: 62%;
            height: 22%;
            border-radius: 50%;
            background: color-mix(in srgb, var(--fg, #14181f) 32%, transparent);
            filter: blur(5px);
            opacity: 0;
            /* INVARIANT: centering (xPercent/yPercent -50) and scaleX are set
               by the FIRST GSAP tween below, not here - GSAP overwrites the
               whole transform, so a static translate()+scaleX() here would be
               discarded the instant the timeline renders
               (gsap_css_transform_conflict). */
            transform-origin: 50% 50%;
            will-change: transform, opacity;
          }
        </style>

        <div
          id="root"
          data-composition-id="touch-indicator"
          data-composition-variables='[
            {"id":"gesture","type":"enum","label":"Gesture","default":"tap","options":[{"value":"tap","label":"Tap"},{"value":"swipe","label":"Swipe"}]},
            {"id":"polarity","type":"enum","label":"Polarity","default":"light","options":[{"value":"light","label":"Light (on dark UI)"},{"value":"dark","label":"Dark (on light UI)"}]},
            {"id":"targetX","type":"number","label":"Target X","default":50,"min":0,"max":100,"unit":"%"},
            {"id":"targetY","type":"number","label":"Target Y","default":55,"min":0,"max":100,"unit":"%"}
          ]'
        >
          <div class="hf-touch-indicator__pulse"></div>
          <div class="hf-touch-indicator__actor">
            <div class="hf-touch-indicator__contact"></div>
            <div class="hf-touch-indicator__core"></div>
          </div>
        </div>

        <script>
          (function () {
            const FPS = 60;
            const q = (s) => Math.max(1, Math.round(s * FPS)) / FPS;
            const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));

            // Coordinate law from the spec: the actor wrapper owns x/y
            // (absolute position), the core owns press scale and the
            // depth/lift offset, the contact shadow owns only its own opacity
            // and scaleX, the root pulse owns only its own position/opacity/
            // scale. Never tween two meanings on one transform channel.
            function buildTouchIndicator(tl, root, { at = 0, elasticity = 1 } = {}) {
              const defaults = { gesture: "tap", polarity: "light", targetX: 50, targetY: 55 };
              // Per-instance overrides (a host's data-variable-values) land in
              // window.__hfVariablesByComp, scoped by composition id. Reading
              // window.__hfVariables directly here would silently ignore every
              // host override -- window.__hyperframes.getVariables() is the
              // scoped accessor, per the registry convention (see
              // lower-third-bild.html, caption-texture.html).
              const resolved =
                window.__hyperframes && window.__hyperframes.getVariables
                  ? window.__hyperframes.getVariables()
                  : window.__hfVariables || {};
              const vars = Object.assign({}, defaults, resolved);
              const pulse = root.querySelector(".hf-touch-indicator__pulse");
              const actor = root.querySelector(".hf-touch-indicator__actor");
              const core = root.querySelector(".hf-touch-indicator__core");
              const contact = root.querySelector(".hf-touch-indicator__contact");
              if (vars.polarity === "dark") actor.classList.add("hf-touch-indicator__actor--dark");

              // Compute layout geometry once at setup, never inside an ease/callback.
              const hostW = root.clientWidth || 1;
              const hostH = root.clientHeight || 1;
              const x0 = (vars.targetX / 100) * hostW;
              const y0 = (vars.targetY / 100) * hostH;
              tl.set(pulse, { x: x0, y: y0, xPercent: -50, yPercent: -50 }, at);

              // --- IN: fixed, never stretched. ---
              const approach = q(0.09);
              const down = q(0.08);
              tl.fromTo(
                actor,
                { x: x0, y: y0, xPercent: -50, yPercent: -50, autoAlpha: 0 },
                {
                  x: x0,
                  y: y0,
                  xPercent: -50,
                  yPercent: -50,
                  autoAlpha: 1,
                  duration: approach,
                  ease: "power2.out",
                  immediateRender: false,
                },
                at,
              );
              tl.fromTo(
                core,
                { y: -10, scale: 0.72 },
                { y: 0, scale: 1, duration: approach, ease: "power2.out", immediateRender: false },
                at,
              );

              const touchDown = at + approach; // sync point: fixed +0.083s into IN
              const coreDownScale = vars.gesture === "swipe" ? 0.86 : 0.82;
              tl.fromTo(
                core,
                { scale: 1 },
                { scale: coreDownScale, duration: down, ease: "power2.in", immediateRender: false },
                touchDown,
              );
              tl.fromTo(
                contact,
                { autoAlpha: 0, scaleX: 0.62, xPercent: -50, yPercent: -50 },
                {
                  autoAlpha: 0.34,
                  scaleX: 1,
                  xPercent: -50,
                  yPercent: -50,
                  duration: down,
                  ease: "power2.in",
                  immediateRender: false,
                },
                touchDown,
              );
              // Built-in root pulse: same-frame acknowledgement so the
              // gesture reads even with no host UI wired to react to it.
              tl.fromTo(
                pulse,
                { autoAlpha: 0, scale: 0.4 },
                {
                  autoAlpha: 0.35,
                  scale: 1,
                  duration: down,
                  ease: "power2.out",
                  immediateRender: false,
                },
                touchDown,
              );
              tl.to(
                pulse,
                { autoAlpha: 0, scale: 1.4, duration: q(0.24), ease: "power2.out" },
                touchDown + down,
              );

              const inEnd = at + approach + down;

              // --- HOLD: the only elastic phase. Stretch it, never gsap.timeScale(). ---
              let holdEnd;
              if (vars.gesture === "swipe") {
                // RETIME RANGE: 0.28s - 0.52s per the swipe law. elasticity scales pacing.
                const swipeDuration = q(clamp(0.34 * elasticity, 0.28, 0.52));
                const travel = hostH * 0.4; // vertical scroll, 40% of host height (32-68% allowed)
                const accel = q(swipeDuration * 0.41);
                const decel = swipeDuration - accel;
                const bendY = y0 - travel * 0.41;
                const endY = y0 - travel;
                tl.fromTo(
                  actor,
                  { x: x0, y: y0 },
                  { x: x0, y: bendY, duration: accel, ease: "power2.in", immediateRender: false },
                  inEnd,
                );
                tl.to(actor, { x: x0, y: endY, duration: decel, ease: "power3.out" }, inEnd + accel);
                holdEnd = inEnd + swipeDuration;
              } else {
                // RETIME RANGE: 0.02s - 0.10s. A brief contact dwell, never a
                // long-press (long-press is a distinct gesture with its own
                // progress-ring recognizer).
                const holdDuration = q(clamp(0.02 * elasticity, 0.02, 0.1));
                holdEnd = inEnd + holdDuration;
              }

              // --- OUT: fixed, never stretched. ---
              if (vars.gesture === "swipe") {
                const liftDuration = q(0.12);
                tl.to(contact, { autoAlpha: 0, duration: q(0.08), ease: "power2.out" }, holdEnd);
                tl.to(
                  core,
                  { y: -10, scale: 0.74, duration: liftDuration, ease: "power2.in" },
                  holdEnd,
                );
                tl.to(actor, { autoAlpha: 0, duration: liftDuration, ease: "power2.in" }, holdEnd);
                return { touchDown, holdStart: inEnd, holdEnd, settle: holdEnd + liftDuration };
              }

              const release = q(0.16);
              tl.to(core, { scale: 1, duration: release, ease: "power2.out" }, holdEnd);
              tl.to(
                contact,
                { autoAlpha: 0, scaleX: 0.72, duration: q(0.12), ease: "power2.out" },
                holdEnd,
              );
              const liftStart = holdEnd + release; // 0s gap, within the spec's <=0.04s allowance
              const liftDuration = q(0.12);
              tl.to(
                core,
                { y: -10, scale: 0.74, duration: liftDuration, ease: "power2.in" },
                liftStart,
              );
              tl.to(actor, { autoAlpha: 0, duration: liftDuration, ease: "power2.in" }, liftStart);
              return { touchDown, holdStart: inEnd, holdEnd, settle: liftStart + liftDuration };
            }

            // Self-driving mount: build the paused timeline and register it
            // under this file's own data-composition-id, matching the host's
            // data-composition-src wiring (sub-compositions.md, Pitfall 2).
            const root = document.querySelector('[data-composition-id="touch-indicator"]');
            const tl = gsap.timeline({ paused: true });
            window.__timelines = window.__timelines || {};
            window.__timelines["touch-indicator"] = tl;
            buildTouchIndicator(tl, root, { at: 0 });
          })();
        </script>
      </template>
    </body>
  </html>
  ```
</Accordion>

Tagged `motion-primitive` `mobile` `gesture` `touch` `pointer`.

## Related topics

* [Browse the complete Catalog](/catalog)
* [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
* [Build a richer composition](/go-further)


## Related topics

- [Toggle Flip](/catalog/components/toggle-flip.md)
- [Tabs Slide Indicator](/catalog/components/tabs-slide-indicator.md)
- [Soft Blob Touch](/catalog/components/soft-blob-touch.md)
