Button

Components

Button

shadcn's Button with goo. Hover to drip, press to squish, tap for a single drip.

Hover, swipe through fast, click. Try Let it fly.

Customize

Preset
Colour
Finish
Drip size 7.0px
Drip length 14px
Press squish 0.08
Wet gloss 0.00
Reach distance 0px

Installation

Nuxt

pnpm add @goopui/nuxt
nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@goopui/nuxt'],
})

The module auto-imports GoopButton, adds goop.css, and points Tailwind at the component classes. It needs Tailwind CSS v4 and the shadcn tokens (--primary, --secondary, --destructive, --border, --input, --background, --muted, --ring, --radius).

Vue

pnpm add @goopui/vue
main.css
@import "tailwindcss";
@import "@goopui/vue/goop.css";
main.ts
import { createApp } from 'vue'
import { GoopPlugin } from '@goopui/vue'
import App from './App.vue'

createApp(App)
  .use(GoopPlugin, { preset: 'playful' }) // optional: app-wide defaults
  .mount('#app')

Usage

<script setup lang="ts">
// Nuxt auto-imports this. In plain Vue, import it:
import { GoopButton } from '@goopui/vue'
</script>

<template>
  <GoopButton>Save</GoopButton>
  <GoopButton variant="destructive">Delete</GoopButton>
</template>

Variants

The same variants as shadcn. The goo takes each variant's color: solid for default and secondary, a true tint for destructive, a 1px goo stroke for outline, and hover-only goo for ghost. Link has no goo, since a text link has no surface to drip from.

<GoopButton>Default</GoopButton>
<GoopButton variant="secondary">Secondary</GoopButton>
<GoopButton variant="destructive">Destructive</GoopButton>
<GoopButton variant="outline">Outline</GoopButton>
<GoopButton variant="ghost">Ghost</GoopButton>
<GoopButton variant="link">Link</GoopButton>

Behavior

  • Hover spawns drips off the bottom edge. They swell and neck. If you keep hovering they detach, fall, shrink, and fade; if the pointer leaves first, they get pulled back up. Resting the pointer keeps a slow drip going (--goop-drip-interval).
  • Reach. Before the pointer even arrives, one to three lumps swell out of the edge toward it (--goop-reach, --goop-reach-globs, --goop-reach-size). Come in slowly from below to see them.
  • Speed matters. A fast entry spawns more drips in a tighter cluster, and a fast hit (past --goop-splash-min-speed) fires a click-like shockwave aimed along the motion (--goop-hit-effect).
  • Momentum. The body leans and wobbles with the pointer, hanging drips swing with its acceleration (--goop-inertia) and creep along the edge toward the cursor (--goop-flow-to-cursor), and fast page scrolls make them slosh (--goop-scroll-slosh).
  • Taffy. Press and drag off the button to pull a stretchy string of goo, with a couple of extra strands, until it snaps (--goop-taffy, --goop-taffy-length, --goop-taffy-strands). On touch it starts after a long press, so scrolling still works.
  • Label ride. The label moves and squishes with the body; transforms only, so the text stays sharp (--goop-label-ride).
  • Click squishes the body, then on release adds goo (it swells and drips faster) and fires a shockwave from the click point. Space and Enter do the same from the top.
  • Touch gives a squish, a drip, and a shockwave per tap. Hover drips only run on devices with a real hover.
  • Layout never changes. Drips and particles draw in an absolutely positioned overlay with pointer-events: none.

Sizes and shapes

The sizes are shadcn's. The goo follows the element's own box and border-radius, so a pill is just rounded-full:

<GoopButton size="xs">Extra small</GoopButton>
<GoopButton size="sm">Small</GoopButton>
<GoopButton>Default</GoopButton>
<GoopButton size="lg">Large</GoopButton>
<GoopButton size="icon" aria-label="Next"><ArrowRight /></GoopButton>
<GoopButton class="rounded-full">Pill</GoopButton>

asChild renders your element instead of a <button>, with the same classes and goo. Use it with NuxtLink (or RouterLink):

<GoopButton as-child>
  <NuxtLink to="/docs/getting-started/installation">Installation guide</NuxtLink>
</GoopButton>

Presets

subtle, playful (default), and wild set every feature at once. Use subtle in dense UIs, wild for a hero CTA:

<GoopButton preset="subtle">Save</GoopButton>
<GoopButton preset="wild">Get started</GoopButton>

Or from CSS: --goop-preset: wild. The Theming page lists everything each preset changes. To try them on these buttons, use the tuner there.

Feature toggles

Every engine feature is a prop. A boolean switches it off (false) or on at the preset's strength (true), and a number tunes it. Leave it out and the CSS vars and the preset decide.

<GoopButton :shockwave="false" taffy>Taffy, no shockwave</GoopButton>
<GoopButton gloss reach>Gloss and reach</GoopButton>
<GoopButton preset="wild" variant="secondary">Wild</GoopButton>
<GoopButton preset="subtle" variant="outline">Subtle</GoopButton>
<GoopButton :drip-size="12" :inertia="0.4">Tuned with numbers</GoopButton>

For anything not covered by a prop, the goop prop takes a whole options object, and explicit props win over it:

<GoopButton :goop="{ splash: 'escape', restingDrips: [0.3, 0.7] }">Escape artist</GoopButton>

Resting drips

restingDrips hangs still drips off the edge, and they breathe very slowly (--goop-idle). Give it a count, or place them as fractions of the width, or as { x, size, length } objects for an organic, uneven edge. They're also what's left in reduced-motion mode:

<GoopButton :resting-drips="1">One drip</GoopButton>
<GoopButton variant="destructive" :resting-drips="2">Two drips</GoopButton>
<GoopButton class="rounded-full" :resting-drips="[0.2, 0.32, 0.8]" :drip-length="20">
  Placed: 0.2, 0.32, 0.8
</GoopButton>

Oil finish

finish="oil" (or --goop-finish: oil) gives the goo a thin-film sheen. It works on any color, and is strongest on dark ink:

<GoopButton
  size="lg"
  finish="oil"
  :oil-intensity="0.6"
  class="bg-neutral-950 text-white [--goop-color:var(--color-neutral-950)]"
>
  Crude oil
</GoopButton>
<GoopButton size="lg" variant="destructive" finish="oil">Oil on red</GoopButton>

Disabled and drip-free

A disabled button ignores the pointer. Drips already running finish, and nothing new starts. :drips="false" keeps the press squish, and :goo="false" renders a plain shadcn Button:

<GoopButton disabled>Disabled</GoopButton>
<GoopButton :drips="false">No drips, squish only</GoopButton>
<GoopButton :goo="false" variant="outline">Plain shadcn (goo off)</GoopButton>

Driving it from code

GoopButton exposes the engine handle and its methods, so you can drip or splash it from a script, like the Drip button under the demos above does:

<script setup lang="ts">
const btn = useTemplateRef('btn')
</script>

<template>
  <GoopButton ref="btn" :time-scale="0.3">Slow</GoopButton>
  <button @click="btn?.drip()">Drip it</button>
</template>

It exposes handle, el, refresh(), press(), release(), hover(on, x?), drip(x?), addGoop(), shockwave(), splashThrough(), feedPointer(), and inspect(). After changing --goop-* vars on an ancestor, call refresh() so the button re-reads them.

Props

GoopButton keeps the shadcn-vue Button API and adds the goo props.

GoopButton component props
PropTypeDefaultNotes
variant'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link''default'Same as shadcn. The goo takes the variant's color: tinted for destructive, a goo stroke for outline, hover-only for ghost. Link has no goo.
size'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg''default'Same as shadcn.
asChildbooleanfalseRender your own child (a NuxtLink, an <a>) with the button's classes and goo, via reka-ui Primitive.
asstring | Component'button'The element to render.
classstring | array | objectMerged onto the root, like shadcn. Utilities such as bg-*, rounded-*, and [--goop-color:...] win over the variant.
disabledbooleanfalseDisables the button. Drips already running finish; nothing new starts.
goobooleantrue (false for link)Master switch. false renders a plain shadcn Button.
goopGoopOptionsEscape hatch: any engine option as an object. The explicit props win over it.

Engine props

Every engine option is a prop, in camelCase (dripSize becomes :drip-size). This table is generated from the engine's own metadata, so it always matches the version of the docs you're reading. Each prop is also a --goop-* CSS variable. Precedence, high to low: prop, then the goop object, then CSS var, then preset, then default.

GoopButton engine props, generated from GOOP_VAR_META
PropTypeDefaultWhat it does
Shape
softness
--goop-softness
number
0 to 40 px
4How far apart shapes start to melt together; higher looks blobbier with rounder corners, lower looks crisper.
edgeSharpness
--goop-edge-sharpness
number
1 to 100 x
20How hard the goo outline is; lower gives a soft, slightly fuzzy edge.
edge
--goop-edge
number
0.05 to 0.95 0-1
0.4Where the outline sits on the melted shape; lower makes everything fatter and merge from further away, higher makes necks thinner.
gloss
--goop-gloss
boolean | number
0 to 1 0-1
0Opt-in wet look: a soft top-left highlight and rim shading so the goo looks 3D. Off (flat color) by default; `gloss: true` uses 0.6.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
Drips
dripCount
--goop-drip-count
number
0 to 8 count
3Most drips hanging at once while hovered; 0 turns hover drips off.
dripLength
--goop-drip-length
number
0 to 200 px
14How far a drip stretches below the edge before it lets go.
dripSize
--goop-drip-size
number
1 to 60 px
7Radius of a drip bulb when it is full.
dripInterval
--goop-drip-interval
number
0.1 to 10 s
0.9Average time between new drips while the pointer rests on the element; moving and clicking make it faster.
dripVariance
--goop-drip-variance
number
0 to 1 0-1
0.6How different drips look from each other (length, size, neck, spacing); 0 makes them identical.
restingDrips
--goop-resting-drips
number | number[] | object[]
0 to 8 count
0Drips that always hang off the bottom edge, even when nothing happens.
idle
--goop-idle
boolean | number
0 to 1 on/off
1Resting drips swell very slowly so the element never looks frozen.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
maxDrips
--goop-max-drips
number
0 to 48 count
10Hard limit on drips alive at once on this element; raise it for very wide elements.
fallDistance
--goop-fall-distance
number
0 to 400 px
32How far a released drop falls before it has drained away (when puddles are off).
gravity
--goop-gravity
number
0 to 10000 px/s²
700How fast drops and splashes speed up as they fall.
puddles
--goop-puddles
boolean | number
0 to 1 on/off
0Opt-in: falling drops land on an invisible floor below the element, spread flat, then drain; off, they just fall and fade.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
puddleLife
--goop-puddle-life
number
0.2 to 10 s
1.4How long a puddle lasts before it has fully drained.
Motion
thickness
--goop-thickness
number
0.2 to 5 x
1How syrupy the goo is: above 1 is slower and less bouncy like honey, below 1 is quicker and springier.
pointerReaction
--goop-pointer-reaction
boolean | number
0 to 4 x
1Master strength for everything the pointer does to the goo (impact, lean, wobble, sway); 0 ignores pointer motion.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
inertia
--goop-inertia
boolean | number
0 to 4 x
1How much the goo sloshes behind sudden pointer moves, so drips swing and goo piles up on the leading side.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
flowToCursor
--goop-flow-to-cursor
boolean | number
0 to 2 x
1How far hanging drips creep along the edge toward the cursor.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
scrollSlosh
--goop-scroll-slosh
boolean | number
-3 to 3 x
1Fast page scrolling sloshes the goo; negative flips the direction, 0 turns it off.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
tiltStrength
--goop-tilt-strength
number
0 to 3 x
1How much tilting a phone pulls goo sideways (only with the tilt option on).
labelRide
--goop-label-ride
boolean | number
0 to 1 0-1
0.6How much content marked [data-goop-label] moves and squishes along with the goo.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
Touch
squish
--goop-squish
boolean | number
0 to 0.4 0-1
0.08How much the body flattens while pressed.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
clickGoop
--goop-click-goop
boolean | number
0 to 2 x
0.35Extra goo each click adds: the body swells, drips come faster and a splash drip falls.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
clickGoopFade
--goop-click-goop-fade
number
0.1 to 10 s
1.2How long the extra goo from clicks takes to drain back to normal.
keyboard
--goop-keyboard
boolean | number
0 to 1 on/off
1Space or Enter on the focused element squishes it from the top and adds goop.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
taffy
--goop-taffy
boolean | number
0 to 2 x
1Press and drag off the element to pull a stretchy goo string; 0 turns it off.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
taffyLength
--goop-taffy-length
number
20 to 400 px
90How far the string stretches past the edge before it snaps.
reach
--goop-reach
boolean | number
0 to 300 px
0Opt-in: a lump of goo swells out of the edge toward a pointer closer than this, before it touches. Off (0) by default; `reach: true` uses 80.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
reachSize
--goop-reach-size
number
0.5 to 5 x drip size
2.6How big the lump that reaches toward the pointer gets, as a multiple of the drip size.
reachGlobs
--goop-reach-globs
number
1 to 4 count
3Most lumps that swell toward an approaching pointer; extra ones join as it gets nearer or faster.
taffyStrands
--goop-taffy-strands
number
0 to 3 count
2Extra ropes that stretch from the edge into the pulled blob, each letting go at its own length.
globbiness
--goop-globbiness
number
0 to 1 0-1
0.8How fat and lumpy reach lumps and taffy pulls are; 1 is round blobby goo, 0 is a thin smooth string.
Splash
splashMinSpeed
--goop-splash-min-speed
number
0.1 to 5 x1200 px/s
1How fast the pointer must enter (1 = 1200 px/s) to punch goo out of the far side.
splashAmount
--goop-splash-amount
boolean | number
0 to 3 x
1How much goo a punch-through throws; 0 turns splashes off.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
shockwave
--goop-shockwave
boolean | number
0 to 3 x
1Strength of the ring of goo and the edge ripple on each click; 0 turns it off.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
oiliness
--goop-oiliness
number
0 to 1 0-1
0.65Splash character: 0 is a watery spray of droplets, 1 is slow oily clumps that stretch.
strands
--goop-strands
boolean | number
0 to 1 0-1
0.5How long splashes stay joined in strings before they snap; 0 gives loose clumps.
Boolean: false turns it off, true uses the preset's strength, a number tunes it.
hitVariety
--goop-hit-variety
number
0 to 1 0-1
0.7How different each punch-through looks: big lead globs, medium drops and fine specks, a varied cone, tear-off spot and neck; 0 makes every hit the same.
splash
--goop-splash
'near' | 'escape' | 'off' nearWhere splash goo may fly: near the element, across the whole page (escape), or nowhere.
hitEffect
--goop-hit-effect
'shockwave' | 'splash' | 'both' | 'none' shockwaveWhat a fast pointer hit does (a hard entry or a fast swipe across or out): a click-like shockwave, the punch-through glob, both, or nothing.
Features
preset
--goop-preset
'subtle' | 'playful' | 'wild' playfulStarting point for every feature: subtle for dense UIs, playful by default, wild for hero sections.
Finish
finish
--goop-finish
'solid' | 'oil' solidSurface look: plain color, or an oily rainbow sheen at the edges.
oilIntensity
--goop-oil-intensity
number
0 to 1 0-1
0.3How strong the oily rainbow sheen is.
oilBandSize
--goop-oil-band-size
number
0.1 to 10 x
1Size of the rainbow bands; 2 makes them twice as large.
oilHueShift
--goop-oil-hue-shift
number
0 to 1 cycles
0Rotates which colors show in the sheen, like a slightly thicker or thinner oil film.
oilFollowPointer
--goop-oil-follow-pointer
number
0 to 2 0-2
0Lets the rainbow bands drift and swirl with the mouse; 0 keeps them calm.
Also
renderer'svg' | 'canvas''svg'How the goo is drawn. finish="oil" upgrades to canvas.
dripsbooleantruefalse keeps the goo body and squish but never drips.
interactivebooleantruefalse ignores the pointer (static or script-driven goo).
tiltbooleantrueLean toward the pointer.
reducedMotionboolean | 'auto''auto'Force reduced motion on, or ignore the OS setting with false.
seednumberper instanceSeed for the random drips, so a button looks the same on every render.
timeScalenumber1Per-instance time scale: 0.3 runs this button in slow motion.
escapeRenderer'svg' | 'canvas''canvas'Renderer for splashes that leave the button.

Accessibility

It's a real <button> (or your asChild element). The goo layer is aria-hidden and can't be clicked, the label is never filtered, and your focus ring isn't covered. Space and Enter get the same squish and shockwave as a click. Under reduced motion the shape and resting drips stay; drips, squish, momentum, reach, taffy, splashes, and shockwaves turn off, and an oil sheen holds still. See Accessibility & reduced motion.