Button
A soft-canvas button with slowly floating blurred blobs behind a crisp white border.
Live preview and source code for Button
An animated call-to-action button with four blurred blobs drifting slowly
behind a white canvas and a crisp 1px border. The label, the icon and the
colours are all props, so there are no children to pass and nothing to style
before it looks finished. It is a motion.button, so it keeps every native
button behaviour: focus, disabled, form submission and event handlers.
Installation
Copy the source from the Code tab above into your project as Button.tsx. The
styles are inline and the spinner keyframe travels with the file, so there is
no stylesheet to import and no Tailwind config to touch.
It imports framer-motion for the drifting blobs and the press animation, and
lucide-react for the default icon and the loading spinner:
npm install framer-motion lucide-reactThe file is marked "use client", which it needs: the animation runs in the
browser. In an app router project it can still be imported from a server
component.
Usage
Every prop is optional, and the label is a prop rather than a child:
import Button from "./components/Button";
export default function Example() {
return <Button label="Start building" size="lg" blobColor="#c4b5fd" />;
}When to reach for it
The primary action in a hero or a pricing card, where one button is meant to draw the eye and there is room for it to breathe. The soft blob background is built for a light, low-contrast page rather than a dense toolbar.
A single accented action in an otherwise quiet layout: "Start building",
"Upgrade", "Generate". Set blobColor to the accent you already use and the
button picks up the page palette without any other change.
An async action that needs feedback. loading swaps the icon for a spinner and
disables the button in one prop, so there is no separate disabled state to
manage.
When not to
Not in a dense interface. Every instance runs four infinite blob animations, so a toolbar or a table with a button per row pays for motion nobody is looking at. A plain button is the right call there.
Not for navigation. This renders a <button>. A control that changes the
URL should be an <a> so it can be opened in a new tab, and no prop here
changes the element.
Not when the label is long or structured. The label is a single string with
white-space: nowrap, so it will push the button wider rather than wrap. Text
plus a badge, or two lines of copy, needs a different control.
Accessibility
Focus, activation by Enter and Space, and removal from the tab order when
disabled all come from the native <button> underneath. loading sets both
the disabled attribute and aria-busy, but the label does not change, so if
the wait is long, announce the result from a live region rather than relying on
the spinner.
The motion is not gated on prefers-reduced-motion. The blobs drift on a loop
for as long as the button is mounted, and the hover and tap scaling are always
on. If you support a reduced-motion setting, read it in the copied file and
drop the animate values, since there is no prop that turns the animation off.
The label colour is fixed at #3f3f46. canvasColor changes the background
underneath it, so a dark canvas will fail contrast until you change the text
colour in the source too.
Props
Every prop the Button component accepts, with its type and default value.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | Button | Text label rendered inside the button. |
icon | LucideIcon | null | Sparkles | lucide-react icon component. Pass icon={null} to hide it. |
iconPosition | "left" | "right" | left | Where the icon sits relative to the label. |
size | "sm" | "md" | "lg" | lg | Visual size of the button. |
loading | boolean | false | Shows a spinner and disables interaction. |
fullWidth | boolean | false | Stretches the button to fill its container. |
blobColor | string | #7dd3fc | Colour used for all the floating blurred blobs on the canvas. |
canvasColor | string | #ffffff | Base canvas colour the blobs float on top of. |
Notes
The component renders a <style> block defining a global spin keyframe. If
your app already defines spin, one will win over the other.
Props spread onto the button after type="button", so passing type="submit"
works and makes it submit the surrounding form.
style is applied after the component's own inline styles, so anything you
pass through it wins, including borderRadius and boxShadow.
icon={null} removes the icon entirely. Leaving it unset gives you the
Sparkles default, which is rarely what a real call to action wants.