Skip to content

Spring Button ​

A button with springy hover/press physics and a morphing loading state.

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/spring-button.json
Manual installation

Install the dependencies:

bash
npm install motion-v class-variance-authority

Then copy these files into components/ui/spring-button/ (plus lib/utils.ts if you don't have it):

vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { SpringButtonVariants } from '.'
import { AnimatePresence, motion } from 'motion-v'
import { cn } from '@/lib/utils'
import { springButtonVariants } from '.'

const props = withDefaults(defineProps<{
  variant?: SpringButtonVariants['variant']
  size?: SpringButtonVariants['size']
  loading?: boolean
  disabled?: boolean
  type?: 'button' | 'submit' | 'reset'
  class?: HTMLAttributes['class']
}>(), {
  type: 'button',
})

const spring = { type: 'spring', stiffness: 500, damping: 30, mass: 0.6 } as const
</script>

<template>
  <motion.button
    layout
    :type="props.type"
    :disabled="props.disabled || props.loading"
    :aria-busy="props.loading || undefined"
    :while-hover="props.disabled || props.loading ? undefined : { scale: 1.03 }"
    :while-press="props.disabled || props.loading ? undefined : { scale: 0.94 }"
    :transition="spring"
    :class="cn(springButtonVariants({ variant: props.variant, size: props.size }), props.class)"
  >
    <AnimatePresence mode="popLayout" :initial="false">
      <motion.span
        v-if="props.loading"
        key="loading"
        class="inline-flex items-center gap-2"
        :initial="{ opacity: 0, y: 12, filter: 'blur(4px)' }"
        :animate="{ opacity: 1, y: 0, filter: 'blur(0px)' }"
        :exit="{ opacity: 0, y: -12, filter: 'blur(4px)' }"
        :transition="spring"
      >
        <svg class="size-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity="0.25" stroke-width="3" />
          <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
        </svg>
        <slot name="loading" />
      </motion.span>
      <motion.span
        v-else
        key="idle"
        class="inline-flex items-center gap-2"
        :initial="{ opacity: 0, y: 12, filter: 'blur(4px)' }"
        :animate="{ opacity: 1, y: 0, filter: 'blur(0px)' }"
        :exit="{ opacity: 0, y: -12, filter: 'blur(4px)' }"
        :transition="spring"
      >
        <slot />
      </motion.span>
    </AnimatePresence>
  </motion.button>
</template>
ts
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'

export { default as SpringButton } from './SpringButton.vue'

export const springButtonVariants = cva(
  'relative inline-flex items-center justify-center gap-2 overflow-hidden whitespace-nowrap rounded-lg text-sm font-medium outline-none select-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-60 [&_svg]:pointer-events-none [&_svg]:shrink-0',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground shadow-sm',
        secondary: 'bg-secondary text-secondary-foreground',
        outline: 'border bg-background text-foreground shadow-xs hover:bg-accent',
        ghost: 'text-foreground hover:bg-accent',
        destructive: 'bg-destructive text-white shadow-sm',
        brand: 'bg-brand text-black shadow-sm',
      },
      size: {
        sm: 'h-8 px-3 text-xs',
        default: 'h-10 px-4',
        lg: 'h-12 px-6 text-base',
        icon: 'size-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  },
)

export type SpringButtonVariants = VariantProps<typeof springButtonVariants>

Usage ​

ts
import { SpringButton } from '@/components/ui/spring-button'
vue
<SpringButton :loading="saving" @click="save">
  Save changes
  <template #loading>Saving…</template>
</SpringButton>

API ​

PropTypeDefaultDescription
variant'default' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'brand''default'Visual style.
size'sm' | 'default' | 'lg' | 'icon''default'Button size.
loadingbooleanfalseSwaps the label for a spinner + #loading slot and disables the button.
disabledbooleanfalseDisables interaction and press physics.
type'button' | 'submit' | 'reset''button'Native button type.

Designed by Kuldeep Pisda · Released under the MIT License