Spring Button
A button with springy hover/press physics and a morphing loading state.
vue
<script setup lang="ts">
import { SpringButton } from '@/ui/spring-button'
import { ref } from 'vue'
const loading = ref(false)
function save() {
loading.value = true
setTimeout(() => (loading.value = false), 1800)
}
</script>
<template>
<div class="flex flex-wrap items-center justify-center gap-3">
<SpringButton :loading="loading" @click="save">
Save changes
<template #loading>
Saving…
</template>
</SpringButton>
<SpringButton variant="outline">
Outline
</SpringButton>
<SpringButton variant="secondary">
Secondary
</SpringButton>
<SpringButton variant="brand">
Brand
</SpringButton>
</div>
</template>Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/spring-button.jsonManual installation
Install the dependencies:
bash
npm install motion-v class-variance-authorityThen 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
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'brand' | 'default' | Visual style. |
size | 'sm' | 'default' | 'lg' | 'icon' | 'default' | Button size. |
loading | boolean | false | Swaps the label for a spinner + #loading slot and disables the button. |
disabled | boolean | false | Disables interaction and press physics. |
type | 'button' | 'submit' | 'reset' | 'button' | Native button type. |