$scroll / $drag
Create progress-driven animations tied to gestures and scroll.
Quick Start
The property:driver syntax ties any property to a progress source:
// Collapsing toolbar
<Image height:scroll={[320, 56]} />
// Bottom sheet
val sheet = $drag-y({ anchors: [0, 0.7, 1] })
<Box height:sheet={[64, 300, 64]} /> Built-in Drivers
| Driver | Use Case |
|---|---|
scroll, scroll-x, scroll-y | a scrollable's position |
drag-x, drag-y, fling-x, fling-y | a finger; with anchors it snaps, with a range it follows |
pinch, rotate, pan-x, pan-y | transform gestures; several on one element share one state |
tap, double-tap, long-press | toggles |
hover, press, focus | pointer state |
pager, pager-x, pager-y | a pager's position |
loop | a clock, 0 to 1 forever, linear |
back | the back gesture |
tilt-x, tilt-y | the gyroscope, integrated |
Every driver is named the same way in code: $drag-y, $tap, $loop. The table drops the leading $ since a driver name and a
property binding (opacity:tap) both use it bare.
Syntax Levels
Level 1: Built-in Preset
<Image height:scroll={[320, 56]} />
<Card height:tap={[80, 300]} /> Level 2: Built-in + Config
<Image height:scroll={[320, 56], { to: 250 }} />
<Sheet height:drag-y={[64, 500], { velocity: 500 }} /> Level 3: Named Driver
val sheet = $drag-y({ anchors: [0, 0.7, 1], velocity: 500 })
<Image height:sheet={[64, 300, 64]} />
<Text opacity:sheet={[1.0, 0.5, 0.0]} />
<Text fontSize:sheet={[14, 24, 14]} /> Level 4: Expression Form
val scroll = $scroll({ to: 200 })
val pinch = $pinch({ min: 1, max: 3 })
// Multiply two drivers
<Image scale={scroll([1, 1.3]) * pinch([1, 3])} /> See it happen
Compare the three syntax levels — the same idea gets progressively more control.
<LazyColumn>
<Image height:scroll={[320, 56]} src={data.imageUrl} />
</LazyColumn>height:scroll interpolates between two values as the list scrolls, using the preset's own default range.
Collapsing Toolbar Example
<LazyColumn>
<Image
src={show.poster}
height:scroll={[320, 56]}
fit="cover"
/>
<Text
fontSize:scroll={[32, 20]}
opacity:scroll={[1, 0]}
>
{show.name}
</Text>
@for (episode in episodes) {
<EpisodeCard episode={episode} />
}
</LazyColumn> Bottom Sheet Example
val sheet = $drag-y({ anchors: [0, 0.7, 1] })
<Box>
// Main content
<Column>
<Text>Main screen content</Text>
</Column>
// Bottom sheet
<Box
height:sheet={[64, 300, 64]}
background="#1a1a1a"
cornerRadius={16}
>
<Column p={16}>
<Image
width:sheet={[48, 200, 48]}
height:sheet={[48, 200, 48]}
src={song.artwork}
/>
<Text fontSize:sheet={[14, 24, 14]}>
{song.title}
</Text>
</Column>
</Box>
</Box> A Drag That Snaps, and a Drag That Does Not
A drag driver takes anchors or a range, never both.
val sheet = $drag-y({ anchors: [0, 0.6, 1] }) // snaps between stops
val scrub = $drag-x({ from: 0, to: 24 }) // follows the finger; .value is in hours anchors snaps to the nearest stop on release, the way a bottom sheet settles.
A range follows the finger continuously and never snaps. A range's full travel is the
element's own size; distance: overrides it.
A range is a track by default: press anywhere on it and the value jumps to that point, the
way <input type=range> works, so a tap is a seek. grab: "handle" changes that — the value moves by the drag instead, starting from wherever it already is.
val knob = $drag-x({ from: 0, to: 1, grab: "handle" }) // moves by the drag, not to the finger Driver Properties
Named drivers expose runtime properties:
val sheet = $drag-y({ anchors: [0, 0.7, 1] })
sheet.progress // 0.0 to 1.0
sheet.value // in the range's units — hours, pixels, whatever it was declared in
sheet.dragging // true while a finger is on it
sheet.state // anchors only: the current stop's index (0, 1, or 2)
sheet.snapTo(1) // animate to anchor 1
sheet.jumpTo(0) // instant jump to anchor 0
<Text>Sheet position: {sheet.progress}</Text> Morphable Properties
These properties can be driven by gestures/scroll:
- Size: width, height, size
- Position: x, y, offset
- Transform: scale, scaleX, scaleY, rotation
- Appearance: opacity, alpha
- Shape: cornerRadius, borderWidth
- Typography: fontSize, letterSpacing
- Color: backgroundColor, textColor
Cross-Component Sharing
Via Props
// Parent
val mainScroll = $scroll()
<LazyColumn>
<HeroImage scroll={mainScroll} />
</LazyColumn>
// HeroImage.wh
@prop val scroll: Driver
<Image height:scroll={[320, 80]} /> Via Context
// Parent
val mainScroll = $scroll()
$context.set("mainScroll", mainScroll)
// Deeply nested child
val scroll = $context.get("mainScroll")
<Image height:scroll={[320, 80]} /> Interpolation Methods
val sheet = $drag-y({ anchors: [0, 0.7, 1] })
<Image
height:sheet={[48, 300, 48]} // linear (default)
opacity={sheet.step(0.5, 1, 0)} // step function
/> Expandable Card Example
<Card
height:tap={[80, 300]}
p={16}
onClick={() => {}} // Tap toggles
>
<Text>Card Title</Text>
<Text opacity:tap={[0, 1]}>
Expanded content here...
</Text>
</Card> Progress-driven morphing gives you fine-grained control over animations tied to user input. Perfect for bottom sheets, collapsing toolbars, and interactive UI.
See Also
- in: / out: / transition: - Screen transitions
- animate:flip - List animations
- transition:crossfade - Shared element transitions