Pin Element to Playhead
The pin functionality holds an element on screen instead of letting it scroll
away: at the playhead, or against a screen edge, for a set duration or until
something else releases it.
This is useful for long-running cues like audioImpulse where the waveform and its visual feedback would otherwise scroll off-screen while still active, or for any element you want to keep in view.
Holding against the screen — viewport:
Every other form of pin() holds an element against the playhead: it stops
scrolling, but it still zooms and pans with the rest of the score.
pin(viewport:<anchor>) holds it against the screen instead. It keeps the
same place and the same size whatever the zoom, the pan and the playhead are
doing.
pin(viewport:center)
Put that on an Inkscape layer and the whole layer is held: a title page that stays readable at any zoom, a watermark, a panel of controls that has to stay under the performer's thumb.
| Argument | Meaning |
|---|---|
viewport |
Where on screen: center, top, bottom, left, right, topleft, topright, bottomleft, bottomright. |
size |
fit contains it in the viewport (default), cover fills it, actual is one score unit to the pixel, and a number is that percentage of the viewport's width. |
margin |
Inset from the edges in pixels. 24 by default. |
opacity |
0 to 1, for a watermark. |
pin(viewport:center) a title page
pin(viewport:bottomright, size:15, opacity:0.2) a watermark
pin(viewport:top, size:actual, margin:8) a panel of controls
The element stays where the author put it in the score's own markup: nothing is moved to an overlay. So its own cues still register, the DSL editor still edits it, it draws at full vector resolution at any zoom, and how it layers against the score is simply where the layer sits in the file. A layer under the score is a watermark; one over it is a heads-up display.
dur, edge, engage, glide, until and release all describe a hold
against the playhead, and mean nothing to a viewport pin.
Syntax
As a parameter: pin:N
Add pin:N to any cue expression, where N is the duration in seconds:
audioImpulse(path:sfx/rain, rate:30, poly:6, lifetime:region, pin:30)
As a standalone cue: pin(N)
Use pin(N) as the element's id to pin elements that don't have any other DSL expression:
<g id="pin(30)">
<g id="dedication">
<!-- content to be pinned -->
</g>
</g>
Or directly on any element:
<text id="pin(20)">Some important text</text>
The element will scroll normally with the score until its centre reaches the playhead. At that point it hooks to the playhead and stays pinned for the specified duration. When the duration expires, the element is released and drifts off-screen with normal scroll.
Behaviour
Phases
| Phase | What happens |
|---|---|
| Waiting | Element scrolls normally with the score |
| Pinned | Element locked to its anchor — the playhead, or the chosen screen edge. A compensating SVG translate keeps it stationary on screen as the score scrolls beneath |
| Drifting | Let go: the offset is frozen, so the element simply resumes scrolling with the score from where it was held |
| Released | Once fully offscreen, the translate is dropped. The element is exactly where it was drawn, ready for a rewound pass |
A released element does not travel back to its authored position in view — that moved it backwards across whatever was coming up. It scrolls off where it was let go, and the snap home happens only after it has left the screen, where nobody can see it.
The hold duration (dur:) counts wall-clock seconds: inside a speed(3)
region eight seconds are still eight real seconds, and a paused score does
not eat into the hold.
Engage Point
By default the pin engages when playheadX >= element centre X in SVG world
coordinates — the playhead has reached the horizontal midpoint of the cue
element.
With engage:edge the test is different: the element is left to scroll
undisturbed, and the pin engages when the edge anchor reaches its centre.
The anchor is the world position currently under the chosen screen edge, so it
advances with the stage and catches up to the element as the score scrolls.
The element therefore comes to rest exactly where it already was — no leap
across the screen, and nothing to glide.
With engage:trailing the pin waits for the element's trailing (right)
edge to meet the playhead, and then holds with that edge riding the
playhead. This is the one to use on a wide group full of trig:playhead
members — a propagate() cloud, for instance. A centre-engaged pin freezes
the group the moment its midpoint arrives, so the playhead never crosses the
right half and those members never fire. Trailing engagement lets the
playhead sweep every member first, then holds the whole group just behind it:
<g id="pin(engage:trailing, uid:cloudA)">
<g id="cloud" data-oscilla="propagate(scale(values:[1,${1}], trig:playhead, uid:c), rand(0.5,2))"> … </g>
</g>
Rewind Behaviour
If the playhead rewinds past the element centre while pinned or released, the pin resets to waiting. On the next forward pass, it will re-engage.
Full rewind to start (rewindToStart) clears all pins, resetting transforms and phases. The wrapper <g> element stays in place so pins re-engage on the next play-through without needing to re-scan the score.
Playback Guard
Pins only update while playback is active (window.isPlaying). They will not falsely engage during pause, seeking, or at rest.
Parameters
| Key | Value | Description |
|---|---|---|
pin |
number (seconds) | As a parameter on another cue: duration to stay pinned to the playhead |
dur |
number (seconds) | Standalone pin(): how long to hold, in wall-clock seconds. 0 or absent: held until released (see below) or until the score rewinds |
edge |
playhead (default) start end left right |
Where the element is held. start is the edge where passed material leaves the screen (left for the usual scroll direction, right for a reversed score); end the opposite |
engage |
playhead trailing or edge |
When the hold starts: centre at the playhead (default); trailing edge at the playhead, so a wide group is swept entirely before it holds; or on reaching the screen edge, with no leap |
glide |
number (seconds) | Travel time from the playhead to the chosen edge once the pin engages. Default 0.5 when an edge is set |
uid |
name | Names this pin so a release point can let it go |
until |
element id | The pin names its own release: when the element with that SVG id (or cue uid) reaches the playhead, this pin lets go — one object, no pin() on the other element |
release |
uid | Makes the element a release point: when its centre reaches the playhead the held element with that uid is released. Nothing on this element is pinned. The two-object form of until: — use it when several pins share one release, or the release point carries its own cues |
Holding at a screen edge
A pin at the playhead keeps the element in the middle of the screen, on top of whatever comes next. For box notation — a box of material with an extent arrow saying how long to play it — that is the wrong place: the performer needs the box readable while the arrow scrolls under the playhead. Hold it at the start edge instead, and let the arrow head release it:
<!-- the box: engages at the playhead, glides to the start edge, stays until released -->
<g id="pin(edge:start, uid:boxA)"> … the material … </g>
<!-- the arrow head, drawn at the end of the extent line -->
<path id="pin(release:boxA)" d="…"/>
When the box's centre reaches the playhead it is picked up and glides (half a second by default) to the leading edge of the screen, where it stays as the score scrolls on. When the arrow head reaches the playhead the box is released and drifts off with the scroll. Give it a dur: as well if you want a time limit regardless of the release point.
The same relationship can be authored from the pin's side alone with
until: — name the arrow head's element id and skip the second pin():
<g data-oscilla="pin(edge:start, until:arrowHeadA)" id="boxA"> … the material … </g>
<path id="arrowHeadA" d="…"/>
Arriving at the edge instead of leaping to it
The glide above is right for box notation, where the box has to jump clear of
the playhead so the extent arrow underneath stays readable. It is wrong when
the material should simply come to rest — a cluster that scrolls in and stops,
a standing label. engage:edge gives that: the element scrolls on past the
playhead untouched, and holds the moment it reaches the edge.
<!-- scrolls in, stops at the left edge, stays until released -->
<g id="pin(edge:left, engage:edge, uid:clusterA)"> … </g>
<g id="pin(release:clusterA)"> … at the mark where it lets go … </g>
Pick by what the element is doing: engage:playhead to get it out of the way
of something still to be read, engage:edge to park it once it arrives.
start/end follow the scroll direction, so the same score works reversed; left/right are literal. Vertical scores: pins are horizontal-only for now (the compensating translate is X-only).
Examples
// Impulse pinned for 30 seconds
audioImpulse(path:sfx/rain, rate:30, poly:6, lifetime:region, pin:30)
// Audio file pinned for 10 seconds
audio(src:long-drone, loop:0, fade:5, pin:10)
// Pool pinned for 15 seconds
audioPool(path:sfx/textures, mode:shuffle, pin:15)
// Works with non-audio cues too
fade(mode:pulse, dur:4, pin:20)
// Standalone pin — for elements without DSL
pin(30)
// Hold at the start edge until an arrow head releases it
pin(edge:start, uid:boxA) // on the box
pin(release:boxA) // on the arrow head
// Time-limited and edge-held: 20 s at the right-hand edge, 1 s glide
pin(20, edge:right, glide:1)
// No leap: scroll in and hold on arriving at the left edge
pin(edge:left, engage:edge, uid:clusterA)
// Wrap an existing element to pin it
// <g id="pin(30)"><g id="myContent">...</g></g>
Technical Notes
The pin system wraps each pinned element in an SVG <g class="pin-wrapper"> at registration time (during assignCues). The wrapper receives a translate(offset, 0) transform each frame while pinned. This isolates the pin transform from any animation transforms on the element itself (rotations, scales, fades).
The pin is registered once at score load and persists across rewinds. Only rewindToStart resets the phase; small rewind/forward increments allow the pin to self-correct via the rewind guard in each phase.
Pin updates run in the RAF tick pipeline alongside impulse region checks, synth region checks, and speed position checks.
Demo project
See demo-transport: pin(6) and pin(10) elements holding at the playhead.
http://localhost:8001/?project=demo-transport
Tip: use ← → or ↑ ↓ to navigate the docs