Save button
A bookmark toggle. Outline when unsaved, filled in the brand colour when saved.
npx shadcn@latest add https://ui.goodtabs.co/r/save-button.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/save-button
Unsaved and saved side by side. Click either to toggle it.
With a label
showLabel swaps Save for Saved. Any variant and label pair works.
In a card corner
Mb
Mobbin
mobbin.com
Real app screens and flows, searchable by pattern and tagged by platform.
The ghost variant pinned to the top right of a resource card.
Usage
import { SaveButton } from "@/components/ui/save-button"
<SaveButton onSavedChange={(saved) => save(item.id, saved)} />
<SaveButton saved={isSaved} onSavedChange={setSaved} showLabel />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| saved | boolean | Controlled saved state. Leave undefined to let the button manage it. | |
| defaultSaved | boolean | false | Starting state when uncontrolled. |
| onSavedChange | (saved: boolean) => void | Called with the next state on every toggle. | |
| labels | { save: string; saved: string } | { save: "Save", saved: "Saved" } | Visible text with showLabel, accessible name without it. |
| showLabel | boolean | false | Show the label next to the icon. Switches the size from icon-sm to sm. |
| variant | Button variant | "outline" | Any shadcn button variant. Use ghost inside cards. |
Source
save-button/save-button.tsx
"use client";
import * as React from "react";
import { BookmarkIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type SaveButtonProps = Omit<React.ComponentProps<typeof Button>, "children"> & {
/** Controlled saved state. Leave undefined to let the button manage it. */
saved?: boolean;
/** Starting state when uncontrolled. */
defaultSaved?: boolean;
/** Called with the next state on every toggle. */
onSavedChange?: (saved: boolean) => void;
/** The two labels. Used as the visible text with showLabel and as the accessible name without it. */
labels?: { save: string; saved: string };
/** Show the label next to the icon. */
showLabel?: boolean;
};
/**
* A bookmark toggle. Outline when unsaved, filled in the brand colour when saved.
* The saved mark is one of the two places the accent is allowed as a state colour.
*/
export function SaveButton({
saved: savedProp,
defaultSaved = false,
onSavedChange,
labels = { save: "Save", saved: "Saved" },
showLabel = false,
variant = "outline",
size,
className,
onClick,
...props
}: SaveButtonProps) {
const [savedState, setSavedState] = React.useState(defaultSaved);
const controlled = savedProp !== undefined;
const saved = controlled ? savedProp : savedState;
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
const next = !saved;
if (!controlled) setSavedState(next);
onSavedChange?.(next);
};
return (
<Button
type="button"
variant={variant}
size={size ?? (showLabel ? "sm" : "icon-sm")}
aria-pressed={saved}
aria-label={showLabel ? undefined : labels.save}
data-saved={saved || undefined}
onClick={handleClick}
className={cn(
"transition-colors",
(variant === "outline" || variant === "ghost") && "text-muted-foreground",
className,
)}
{...props}
>
<BookmarkIcon
data-icon={showLabel ? "inline-start" : undefined}
aria-hidden="true"
className={cn(saved && "fill-current text-brand")}
/>
{showLabel ? <span>{saved ? labels.saved : labels.save}</span> : null}
</Button>
);
}
For AI agents
Hand your agent this page, the markdown at /components/save-button/prompt.md, or the Copy AI prompt button above.
When to use
- Bookmarking a resource, article, issue or listing for later
- In a card corner, a toolbar or next to a title, wherever one item can be kept
- When the saved state should be visible at a glance across a grid
When not to use
- For likes, votes or reactions, which carry a count and a different meaning
- For following or subscribing to a feed; use a labelled toggle with its own copy
- As a submit action; it toggles state, it does not send a form
Agent notes
- Uncontrolled by default (defaultSaved); pass saved plus onSavedChange to control it from a store
- Demos that pass onSavedChange or onClick from a server page must themselves be client components
- The saved colour is the text-brand utility, which needs --brand and --color-brand in globals.css; without them the saved state still reads by the filled icon
- aria-pressed carries the state; the accessible name stays the save label so screen readers hear Save, pressed