Files
hyperframes/docs/catalog/components/touch-indicator.mdx
miga-heygenandMiguel Ángel 7b5fdb1ad2 fix(docs): emit catalog source accordion titles as plain strings (#4006)
The generated catalog pages rendered the "Source" accordion title as a
JSX expression, `<Accordion title={`name.html`}>`. Mintlify's search
indexer stringifies expression attribute nodes rather than evaluating
them, so every one of the 399 catalog pages surfaced its Source section
in search results as "[object Object]" (visible on the Scroll Feed page,
among others).

The template literal carried no escaping benefit: every title is a
single-segment `name.html` path, and a double-quoted JSX string handles
it directly. Introduce `mdxStringAttribute`, which emits a plain
`name="value"` attribute and escapes `& " < { }` as HTML character
references (MDX decodes references inside quoted attribute values, and
unlike JSON.stringify it does not rely on backslash escapes, which JSX
strings do not honour). Use it for both the Source accordion title and
the preview iframe title, and regenerate the catalog pages.

Regenerated with `npx tsx scripts/generate-catalog-pages.ts`; the only
change across the 399 pages is the one Accordion title line each.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 11:55:00 -04:00

831 lines
37 KiB
Plaintext

---
title: "Touch Indicator"
description: "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."
---
import { InstallCommand } from "/snippets/install-command.jsx";
import { VariablesExplorer } from "/snippets/variables-explorer.jsx";
## Install
<InstallCommand command="npx hyperframes add touch-indicator" item="touch-indicator" />
That writes one file: `compositions/components/touch-indicator.html`.
<VariablesExplorer
previewSrc="/public/catalog/components/touch-indicator.json"
compositionId="touch-indicator"
compositionSrc="compositions/components/touch-indicator.html"
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":"%"}]}
>
```html touch-indicator.html
<!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>
```
</VariablesExplorer>
## 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
<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
<!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>
{/* hf:generated-footer */}
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)