Variants

Variants allow you to define named animation states that can be referenced throughout your component tree. They’re perfect for creating reusable animations and orchestrating complex sequences.

Basic usage

Instead of defining animation objects inline, you can create a Variants object with named states:

<script lang="ts">
    import { motion, type Variants } from '@humanspeak/svelte-motion'

    let isOpen = $state(false)

    const variants: Variants = {
        open: {
            opacity: 1,
            scale: 1
        },
        closed: {
            opacity: 0,
            scale: 0.8
        }
    }
</script>

<motion.div
    variants={variants}
    initial="closed"
    animate={isOpen ? 'open' : 'closed'}
    onclick={() => isOpen = !isOpen}
>
    Click me
</motion.div>
<script lang="ts">
    import { motion, type Variants } from '@humanspeak/svelte-motion'

    let isOpen = $state(false)

    const variants: Variants = {
        open: {
            opacity: 1,
            scale: 1
        },
        closed: {
            opacity: 0,
            scale: 0.8
        }
    }
</script>

<motion.div
    variants={variants}
    initial="closed"
    animate={isOpen ? 'open' : 'closed'}
    onclick={() => isOpen = !isOpen}
>
    Click me
</motion.div>
mode · live running open

Benefits

1. Reusable animation definitions

Define your animation states once and reference them by name throughout your components:

<script lang="ts">
    const fadeVariants: Variants = {
        visible: { opacity: 1 },
        hidden: { opacity: 0 }
    }
</script>

<motion.div variants={fadeVariants} animate="visible" />
<motion.p variants={fadeVariants} animate="visible" />
<motion.span variants={fadeVariants} animate="visible" />
<script lang="ts">
    const fadeVariants: Variants = {
        visible: { opacity: 1 },
        hidden: { opacity: 0 }
    }
</script>

<motion.div variants={fadeVariants} animate="visible" />
<motion.p variants={fadeVariants} animate="visible" />
<motion.span variants={fadeVariants} animate="visible" />

2. Clean state management

Variants work beautifully with Svelte’s reactive state:

<script lang="ts">
    let status = $state<'idle' | 'loading' | 'success'>('idle')

    const statusVariants: Variants = {
        idle: { scale: 1, backgroundColor: 'gray' },
        loading: { scale: 1.1, backgroundColor: 'royalblue' },
        success: { scale: 1, backgroundColor: '#22c55e' }
    }
</script>

<motion.button variants={statusVariants} animate={status}>
    {status}
</motion.button>
<script lang="ts">
    let status = $state<'idle' | 'loading' | 'success'>('idle')

    const statusVariants: Variants = {
        idle: { scale: 1, backgroundColor: 'gray' },
        loading: { scale: 1.1, backgroundColor: 'royalblue' },
        success: { scale: 1, backgroundColor: '#22c55e' }
    }
</script>

<motion.button variants={statusVariants} animate={status}>
    {status}
</motion.button>

3. Simplified animation orchestration

Variants make it easy to coordinate animations across multiple elements without prop drilling.

Variant propagation

One of the most powerful features of variants is automatic propagation through component trees. When a parent component changes its animation state, all children with matching variant names will animate automatically.

<script lang="ts">
    import { motion, type Variants } from '@humanspeak/svelte-motion'

    let isVisible = $state(false)

    const containerVariants: Variants = {
        visible: { opacity: 1 },
        hidden: { opacity: 0 }
    }

    const itemVariants: Variants = {
        visible: { opacity: 1, x: 0 },
        hidden: { opacity: 0, x: -20 }
    }
</script>

<motion.ul
    variants={containerVariants}
    initial="hidden"
    animate={isVisible ? 'visible' : 'hidden'}
>
    <!-- These children automatically inherit the parent's "visible" or "hidden" state -->
    <motion.li variants={itemVariants}>Item 1</motion.li>
    <motion.li variants={itemVariants}>Item 2</motion.li>
    <motion.li variants={itemVariants}>Item 3</motion.li>
</motion.ul>
<script lang="ts">
    import { motion, type Variants } from '@humanspeak/svelte-motion'

    let isVisible = $state(false)

    const containerVariants: Variants = {
        visible: { opacity: 1 },
        hidden: { opacity: 0 }
    }

    const itemVariants: Variants = {
        visible: { opacity: 1, x: 0 },
        hidden: { opacity: 0, x: -20 }
    }
</script>

<motion.ul
    variants={containerVariants}
    initial="hidden"
    animate={isVisible ? 'visible' : 'hidden'}
>
    <!-- These children automatically inherit the parent's "visible" or "hidden" state -->
    <motion.li variants={itemVariants}>Item 1</motion.li>
    <motion.li variants={itemVariants}>Item 2</motion.li>
    <motion.li variants={itemVariants}>Item 3</motion.li>
</motion.ul>
mode · live running open
  • Item 1
  • Item 2
  • Item 3
  • Item 4

How propagation works

  1. Parent component sets animate="visible"
  2. Children with variants defined automatically inherit "visible"
  3. Each child resolves its own visible variant from its local variants definition
  4. No need to pass animate props to children!

Stagger animations

You can create staggered animations by using the delay in individual item transitions:

<script lang="ts">
    const itemVariants: Variants = {
        visible: { opacity: 1, y: 0 },
        hidden: { opacity: 0, y: 20 }
    }
</script>

{#each items as item, i}
    <motion.div
        variants={itemVariants}
        transition={{
            delay: i * 0.1
        }}
    >
        {item}
    </motion.div>
{/each}
<script lang="ts">
    const itemVariants: Variants = {
        visible: { opacity: 1, y: 0 },
        hidden: { opacity: 0, y: 20 }
    }
</script>

{#each items as item, i}
    <motion.div
        variants={itemVariants}
        transition={{
            delay: i * 0.1
        }}
    >
        {item}
    </motion.div>
{/each}

Complex example: Notifications Stack

Here’s a real-world example showing how variants enable complex orchestration. The parent controls the animation state, and all children respond accordingly.

Click the stack to see variants in action:

mode · live running open

Notifications

Note 1
Note 2
Note 3
State: closed

Breaking down the code

The example uses three different variant definitions working together:

<script lang="ts">
    let isOpen = $state(false)

    // 1. Parent stack variants control the overall scale and position
    const stackVariants: Variants = {
        open: { y: 20, scale: 0.9, cursor: 'pointer' },
        closed: { y: 0, scale: 1, cursor: 'default' }
    }

    // 2. Header variants control visibility
    const headerVariants: Variants = {
        open: { opacity: 1, y: 0, pointerEvents: 'auto' },
        closed: { opacity: 0, y: 60, pointerEvents: 'none' }
    }

    // 3. Each notification gets its own stacking behavior
    function getNotificationVariants(index: number): Variants {
        return {
            open: {
                y: 0,
                opacity: 1,
                pointerEvents: 'auto'
            },
            closed: {
                y: -index * 68,
                opacity: 1 - index * 0.4,
                pointerEvents: index === 0 ? 'auto' : 'none'
            }
        }
    }
</script>

<motion.div
    variants={stackVariants}
    animate={isOpen ? 'open' : 'closed'}
>
    <!-- Header automatically inherits "open" or "closed" state -->
    <motion.div variants={headerVariants}>
        <h2>Notifications</h2>
        <button onclick={() => isOpen = false}>Collapse</button>
    </motion.div>

    <!-- Each notification inherits and applies its own variant -->
    {#each notifications as notification, i}
        <motion.div
            variants={getNotificationVariants(i)}
            transition={{ delay: i * 0.04 }}
            onclick={() => isOpen = !isOpen}
        >
            {notification}
        </motion.div>
    {/each}
</motion.div>
<script lang="ts">
    let isOpen = $state(false)

    // 1. Parent stack variants control the overall scale and position
    const stackVariants: Variants = {
        open: { y: 20, scale: 0.9, cursor: 'pointer' },
        closed: { y: 0, scale: 1, cursor: 'default' }
    }

    // 2. Header variants control visibility
    const headerVariants: Variants = {
        open: { opacity: 1, y: 0, pointerEvents: 'auto' },
        closed: { opacity: 0, y: 60, pointerEvents: 'none' }
    }

    // 3. Each notification gets its own stacking behavior
    function getNotificationVariants(index: number): Variants {
        return {
            open: {
                y: 0,
                opacity: 1,
                pointerEvents: 'auto'
            },
            closed: {
                y: -index * 68,
                opacity: 1 - index * 0.4,
                pointerEvents: index === 0 ? 'auto' : 'none'
            }
        }
    }
</script>

<motion.div
    variants={stackVariants}
    animate={isOpen ? 'open' : 'closed'}
>
    <!-- Header automatically inherits "open" or "closed" state -->
    <motion.div variants={headerVariants}>
        <h2>Notifications</h2>
        <button onclick={() => isOpen = false}>Collapse</button>
    </motion.div>

    <!-- Each notification inherits and applies its own variant -->
    {#each notifications as notification, i}
        <motion.div
            variants={getNotificationVariants(i)}
            transition={{ delay: i * 0.04 }}
            onclick={() => isOpen = !isOpen}
        >
            {notification}
        </motion.div>
    {/each}
</motion.div>

Key points:

  • Parent sets animate={isOpen ? 'open' : 'closed'}
  • Children with variants automatically inherit this state
  • Each child resolves its own variant definition
  • No need to pass props to children!

See the full source code for the complete implementation.

Type safety

Variants are fully typed in TypeScript. The Variants type ensures your animation definitions are valid:

import type { Variants } from '@humanspeak/svelte-motion'

const variants: Variants = {
    visible: {
        opacity: 1,
        x: 0
    },
    hidden: {
        opacity: 0,
        x: -100
    }
}
import type { Variants } from '@humanspeak/svelte-motion'

const variants: Variants = {
    visible: {
        opacity: 1,
        x: 0
    },
    hidden: {
        opacity: 0,
        x: -100
    }
}

Best practices

1. Use semantic names

Choose variant names that describe the state, not the animation:

// ✅ Good - describes state
const variants = {
    visible: { opacity: 1 },
    hidden: { opacity: 0 }
}

// ❌ Avoid - describes animation
const variants = {
    fadeIn: { opacity: 1 },
    fadeOut: { opacity: 0 }
}
// ✅ Good - describes state
const variants = {
    visible: { opacity: 1 },
    hidden: { opacity: 0 }
}

// ❌ Avoid - describes animation
const variants = {
    fadeIn: { opacity: 1 },
    fadeOut: { opacity: 0 }
}

2. Keep variants focused

Each variant should represent a complete state:

const buttonVariants: Variants = {
    idle: { scale: 1, backgroundColor: 'gray' },
    loading: { scale: 1.05, backgroundColor: 'royalblue' },
    success: { scale: 1, backgroundColor: '#22c55e' },
    error: { scale: 0.95, backgroundColor: '#ef4444' }
}
const buttonVariants: Variants = {
    idle: { scale: 1, backgroundColor: 'gray' },
    loading: { scale: 1.05, backgroundColor: 'royalblue' },
    success: { scale: 1, backgroundColor: '#22c55e' },
    error: { scale: 0.95, backgroundColor: '#ef4444' }
}

3. Combine with Svelte state

Variants work perfectly with Svelte’s reactive state management:

<script lang="ts">
    let state = $state<'initial' | 'active' | 'complete'>('initial')
</script>

<motion.div variants={myVariants} animate={state}>
    <!-- Component updates automatically when state changes -->
</motion.div>
<script lang="ts">
    let state = $state<'initial' | 'active' | 'complete'>('initial')
</script>

<motion.div variants={myVariants} animate={state}>
    <!-- Component updates automatically when state changes -->
</motion.div>

API Reference

Variants type

type Variants = Record<string, DOMKeyframesDefinition | undefined>
type Variants = Record<string, DOMKeyframesDefinition | undefined>

A Variants object is a dictionary mapping variant names (strings) to animation definitions.

Using variants

Variants can be passed to any motion component via the variants prop:

  • variants: Object containing named animation states
  • initial: Initial variant name (string or string[])
  • animate: Target variant name (string or string[])
  • exit: Exit variant name (string or string[])
  • whileHover / whileTap / whileFocus / whileDrag / whileInView: variant name (string or string[])
<motion.div
    variants={myVariants}
    initial="hidden"
    animate="visible"
    exit="hidden"
/>
<motion.div
    variants={myVariants}
    initial="hidden"
    animate="visible"
    exit="hidden"
/>

Variant keys on gesture props

Pass a variant name to any whileX prop to reuse a named state across gestures — handy when the same animation target shows up in multiple places (a single hover variant can drive whileHover on every card in a list, for example).

<script lang="ts">
    import { motion, type Variants } from '@humanspeak/svelte-motion'

    const cardVariants: Variants = {
        hovered: { scale: 1.05 },
        pressed: { scale: 0.95 }
    }
</script>

<motion.div variants={cardVariants} whileHover="hovered" whileTap="pressed">
    hover or tap
</motion.div>
<script lang="ts">
    import { motion, type Variants } from '@humanspeak/svelte-motion'

    const cardVariants: Variants = {
        hovered: { scale: 1.05 },
        pressed: { scale: 0.95 }
    }
</script>

<motion.div variants={cardVariants} whileHover="hovered" whileTap="pressed">
    hover or tap
</motion.div>

You can also pass an array of variant keys to combine multiple states. The keys merge left-to-right — later entries override earlier ones on key collisions.

<motion.div
    variants={cardVariants}
    whileHover={["hovered", "tinted"]}
/>
<motion.div
    variants={cardVariants}
    whileHover={["hovered", "tinted"]}
/>

If a key is missing from variants, it’s silently skipped — useful for conditional layering.

Related