Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
savedbooleanControlled saved state. Leave undefined to let the button manage it.
defaultSavedbooleanfalseStarting state when uncontrolled.
onSavedChange(saved: boolean) => voidCalled with the next state on every toggle.
labels{ save: string; saved: string }{ save: "Save", saved: "Saved" }Visible text with showLabel, accessible name without it.
showLabelbooleanfalseShow the label next to the icon. Switches the size from icon-sm to sm.
variantButton 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