useCycle
useCycle cycles through a series of values. It pairs naturally with motion variants or any prop you want to toggle on user interaction.
<script>
import { motion, useCycle } from '@humanspeak/svelte-motion'
const x = useCycle(0, 50, 100)
</script>
<motion.div animate={{ x: x.current }} onclick={() => x.cycle()} /><script>
import { motion, useCycle } from '@humanspeak/svelte-motion'
const x = useCycle(0, 50, 100)
</script>
<motion.div animate={{ x: x.current }} onclick={() => x.cycle()} />useCycle returns a { current, cycle } object:
currentis a Svelte 5 reactive getter backed by$state— read it in templates,$derived, or$effectand it tracks automatically.cycle()advances to the next item, wrapping back to the first when it passes the end.cycle(i)jumps directly to the value at indexi.
Three deliberate divergences from React framer-motion’s
useCycle:
- Return shape — React returns
[value, cycle]. Under Svelte 5 runes, destructuring a$state-backed value snapshots it and loses reactivity, so we return an object whose getter preserves tracking.- Out-of-range reads always clamp instead of returning
items[i]undefined — see API Reference → Notes below.cycle(next)requires an integer index and throws onNaN,Infinity, or fractional values. React would silently make.currentresolve toundefined; we surface the bug at write time.Otherwise 1:1 with React, including same-index no-op bail-out and
wrap(0, length, index + 1)advance semantics.
Cycling through variants
The most common use is toggling between named variants on a motion component. Pass the variant names to useCycle and bind .current to the animate prop:
<script lang="ts">
import { motion, useCycle } from '@humanspeak/svelte-motion'
const variants = {
rest: { x: 0, rotate: 0 },
nudge: { x: 80, rotate: 8 },
flip: { x: 80, rotate: 188 }
}
const variant = useCycle<keyof typeof variants>('rest', 'nudge', 'flip')
</script>
<motion.button
{variants}
animate={variant.current}
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
onclick={() => variant.cycle()}
>
Cycle
</motion.button><script lang="ts">
import { motion, useCycle } from '@humanspeak/svelte-motion'
const variants = {
rest: { x: 0, rotate: 0 },
nudge: { x: 80, rotate: 8 },
flip: { x: 80, rotate: 188 }
}
const variant = useCycle<keyof typeof variants>('rest', 'nudge', 'flip')
</script>
<motion.button
{variants}
animate={variant.current}
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
onclick={() => variant.cycle()}
>
Cycle
</motion.button>Jumping to a specific index
Pass a number to jump directly to that index instead of advancing one step. Subsequent cycle() calls advance from the new position:
<script>
import { useCycle } from '@humanspeak/svelte-motion'
const x = useCycle('a', 'b', 'c', 'd')
x.cycle(2) // x.current is now 'c'
x.cycle() // x.current is now 'd'
x.cycle() // x.current wraps to 'a'
</script><script>
import { useCycle } from '@humanspeak/svelte-motion'
const x = useCycle('a', 'b', 'c', 'd')
x.cycle(2) // x.current is now 'c'
x.cycle() // x.current is now 'd'
x.cycle() // x.current wraps to 'a'
</script>Cycling through object items
useCycle preserves referential identity when cycling through objects, so animation targets, variant definitions, and other complex values stay stable:
<script>
import { motion, useCycle } from '@humanspeak/svelte-motion'
const target = useCycle(
{ x: 0, scale: 1 },
{ x: 100, scale: 1.2 },
{ x: 0, scale: 0.8 }
)
</script>
<motion.div animate={target.current} onclick={() => target.cycle()} /><script>
import { motion, useCycle } from '@humanspeak/svelte-motion'
const target = useCycle(
{ x: 0, scale: 1 },
{ x: 100, scale: 1.2 },
{ x: 0, scale: 0.8 }
)
</script>
<motion.div animate={target.current} onclick={() => target.cycle()} />Reactive items
The varargs form captures items once at call time. If your list of items can change — for example, it comes from a prop, a $state, or a $derived — pass a getter function instead. The cycle re-reads through the getter on every access, so list changes propagate automatically:
<script lang="ts">
import { motion, useCycle } from '@humanspeak/svelte-motion'
let { labels }: { labels: string[] } = $props()
const variant = useCycle(() => labels)
</script>
<motion.div animate={variant.current} onclick={() => variant.cycle()} /><script lang="ts">
import { motion, useCycle } from '@humanspeak/svelte-motion'
let { labels }: { labels: string[] } = $props()
const variant = useCycle(() => labels)
</script>
<motion.div animate={variant.current} onclick={() => variant.cycle()} />This mirrors React framer-motion’s behavior where useCycle(...items) re-binds on every render: when items changes, the cycle picks up the new list while keeping the same internal index. If the new list is shorter than the current index, .current clamps to the last valid item rather than returning undefined.
The getter form is detected when
useCycleis called with a single function argument. To cycle through a single function value (uncommon), useuseCycle(() => [fn])— but a single-item cycle is a no-op anyway, so this rarely matters.
API Reference
Parameters
Two call forms:
- Varargs —
useCycle(...items: T[])— captures items at construction time. Matches React framer-motion’s signature. - Reactive getter —
useCycle(getItems: () => readonly T[])— re-reads items on every access so reactive sources propagate.
Must resolve to at least one item or useCycle throws.
Returns
A CycleState<T> object:
currentT(getter) — the current item, starting atitems[0]. Tracks reactively via$state. Clamps to the last valid index if items shrink underneath.cycle(next?: number)— advance to the next item, or jump to the value at indexnextwhen supplied. No-ops if the resolved index is unchanged.
Notes
- Out-of-range indexes are stored as-given by
cycle(i)but.currentalways clamps on read —x.cycle(99)on a 3-item cycle yieldsitems[2],x.cycle(-5)yieldsitems[0]. This is a defensive divergence from React framer-motion (which returnsitems[i], possibly undefined) so.currentalways honors itsTtype. Subsequentcycle()advances are relative to the stored (unclamped) index, socycle(99); cycle()on a 3-item cycle wraps viawrap(0, 3, 100) = 1. - If the reactive getter form’s items list empties mid-cycle,
.currentthrows (useCycle items getter returned an empty list). The public type isT, so silently returningundefinedthere would be a lie — surfacing the bug loudly is the better trade. cycle(next)throws (useCycle index must be a finite integer) whennextisNaN,Infinity, or a fractional number. Those slip past the read-time clamp (NaN comparisons return false for both< 0and>= length) and would otherwise silently make.currentresolve toundefined.- Same-index calls are no-ops and don’t trigger downstream reactivity, matching React
useState’sObject.isbail-out. - File name is
cycle.svelte.tsbecause the hook uses$stateat module scope; if you reach into the source, the.svelte.tsextension is required for runes to compile.
See also
- Variants — declarative animation states that pair naturally with
useCycle. - AnimatePresence — coordinate enter/exit animations as cycled state changes.
Based on Motion’s useCycle API.