Mikkel Manniche
Dansk English Deutsch

Kontroller

Save toggle

En gem-knap, der krymper til en spinner og slår et flueben ud, når den har gemt.

Tilpasset fra Watermelon UI (MIT) og skrevet om med tastatur, etiketter og reduceret bevægelse.

Forhåndsvisning

Brug

save-toggle-demo.tsx
import { useState } from 'react'
import { SaveToggle } from '@/components/save-toggle'

export default function SaveToggleDemo() {
  const [saved, setSaved] = useState(false)

  return (
    <div className="mx-auto flex w-full max-w-sm items-center gap-4 rounded-3xl border bg-card p-4 shadow-sm">
      <div className="grid size-16 shrink-0 place-items-center rounded-2xl bg-muted text-2xl" aria-hidden>
        🧥
      </div>
      <div className="min-w-0 flex-1">
        <p className="truncate font-medium">Waxed field jacket</p>
        <p className="text-sm text-muted-foreground">Olive · $189</p>
      </div>
      <SaveToggle
        saved={saved}
        onSave={async () => {
          await new Promise((r) => setTimeout(r, 900))
          setSaved(true)
        }}
        onUnsave={() => setSaved(false)}
      />
    </div>
  )
}

Kilde

save-toggle.tsx
// Based on Watermelon UI's “Save toggle” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten to wait for a real save and to follow the theme through CSS variables.
import { Check, LoaderCircle } from 'lucide-react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'

export type SaveToggleProps = {
  saved: boolean
  /** Runs the save. The button spins until it settles; a rejection puts it back. */
  onSave: () => Promise<void> | void
  onUnsave: () => void
  label?: string
  savedLabel?: string
  className?: string
}

type Phase = 'idle' | 'saving' | 'done'

/** A save button that shrinks to a spinner, pops a check, and widens again as “Saved”. Press again to unsave. */
export function SaveToggle({ saved, onSave, onUnsave, label = 'Save', savedLabel = 'Saved', className }: SaveToggleProps) {
  const [phase, setPhase] = useState<Phase>('idle')
  const alive = useRef(true)
  useEffect(() => {
    alive.current = true
    return () => {
      alive.current = false
    }
  }, [])

  useEffect(() => {
    if (phase !== 'done') return
    const t = setTimeout(() => setPhase('idle'), 700)
    return () => clearTimeout(t)
  }, [phase])

  const press = async () => {
    if (phase !== 'idle') return
    if (saved) return onUnsave()
    setPhase('saving')
    try {
      await onSave()
      if (alive.current) setPhase('done')
    } catch {
      if (alive.current) setPhase('idle')
    }
  }

  const round = phase !== 'idle'
  const shown = phase === 'saving' ? 'spin' : phase === 'done' ? 'check' : saved ? 'saved' : 'save'
  const swap = { initial: { opacity: 0, y: 12, filter: 'blur(3px)' }, animate: { opacity: 1, y: 0, filter: 'blur(0px)' }, exit: { opacity: 0, y: -12, filter: 'blur(3px)' } }

  return (
    <MotionConfig reducedMotion="user" transition={{ type: 'spring', stiffness: 260, damping: 22 }}>
      <motion.button
        type="button"
        aria-pressed={saved}
        aria-busy={phase === 'saving'}
        onClick={press}
        initial={false}
        animate={{ width: round ? 48 : 116 }}
        className={cn(
          'relative grid h-12 place-items-center overflow-hidden rounded-full font-medium transition-[background-color,color,box-shadow] duration-200 active:scale-[0.97]',
          round ? 'bg-foreground text-background' : saved ? 'bg-card text-foreground ring-2 ring-border' : 'bg-muted text-foreground',
          className,
        )}
      >
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span key={shown} {...swap} className="col-start-1 row-start-1 flex items-center gap-1.5 whitespace-nowrap">
            {shown === 'spin' && <LoaderCircle className="size-5 animate-spin motion-reduce:animate-none" aria-hidden />}
            {shown === 'check' && (
              <motion.span initial={{ scale: 0.4 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 500, damping: 14 }}>
                <Check className="size-5" strokeWidth={3} aria-hidden />
              </motion.span>
            )}
            {shown === 'saved' && (
              <>
                <span className="grid size-5 place-items-center rounded-full bg-foreground text-background">
                  <Check className="size-3" strokeWidth={3.5} aria-hidden />
                </span>
                {savedLabel}
              </>
            )}
            {shown === 'save' && label}
          </motion.span>
        </AnimatePresence>
        <span className="sr-only" aria-live="polite">
          {phase === 'done' ? savedLabel : ''}
        </span>
      </motion.button>
    </MotionConfig>
  )
}

Installér

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/save-toggle.json

Med registret tilføjet (components.json)

npx shadcn@latest add @manniche/save-toggle

Filer

  • save-toggle.tsx

Manniche Lab · open source

Gratis værktøjer til AI-agenter og webudvikling.