Documentation

$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

DriverUse Case
scroll, scroll-x, scroll-ya scrollable's position
drag-x, drag-y, fling-x, fling-ya finger; with anchors it snaps, with a range it follows
pinch, rotate, pan-x, pan-ytransform gestures; several on one element share one state
tap, double-tap, long-presstoggles
hover, press, focuspointer state
pager, pager-x, pager-ya pager's position
loopa clock, 0 to 1 forever, linear
backthe back gesture
tilt-x, tilt-ythe 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.

Sheet.wh
<LazyColumn>
  <Image height:scroll={[320, 56]} src={data.imageUrl} />
</LazyColumn>
Kotlin waiting
Scroll into view to compile.

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