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:

  • current is a Svelte 5 reactive getter backed by $state — read it in templates, $derived, or $effect and 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 index i.

Three deliberate divergences from React framer-motion’s useCycle:

  1. 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.
  2. Out-of-range reads always clamp instead of returning items[i] undefined — see API Reference → Notes below.
  3. cycle(next) requires an integer index and throws on NaN, Infinity, or fractional values. React would silently make .current resolve to undefined; 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.

mode · live running open
rest
advance
rest
nudge
flip
spin

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 useCycle is called with a single function argument. To cycle through a single function value (uncommon), use useCycle(() => [fn]) — but a single-item cycle is a no-op anyway, so this rarely matters.

API Reference

Parameters

Two call forms:

  • VarargsuseCycle(...items: T[]) — captures items at construction time. Matches React framer-motion’s signature.
  • Reactive getteruseCycle(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:

  • current T (getter) — the current item, starting at items[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 index next when supplied. No-ops if the resolved index is unchanged.

Notes

  • Out-of-range indexes are stored as-given by cycle(i) but .current always clamps on read — x.cycle(99) on a 3-item cycle yields items[2], x.cycle(-5) yields items[0]. This is a defensive divergence from React framer-motion (which returns items[i], possibly undefined) so .current always honors its T type. Subsequent cycle() advances are relative to the stored (unclamped) index, so cycle(99); cycle() on a 3-item cycle wraps via wrap(0, 3, 100) = 1.
  • If the reactive getter form’s items list empties mid-cycle, .current throws (useCycle items getter returned an empty list). The public type is T, so silently returning undefined there would be a lie — surfacing the bug loudly is the better trade.
  • cycle(next) throws (useCycle index must be a finite integer) when next is NaN, Infinity, or a fractional number. Those slip past the read-time clamp (NaN comparisons return false for both < 0 and >= length) and would otherwise silently make .current resolve to undefined.
  • Same-index calls are no-ops and don’t trigger downstream reactivity, matching React useState’s Object.is bail-out.
  • File name is cycle.svelte.ts because the hook uses $state at module scope; if you reach into the source, the .svelte.ts extension 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.