Skip to content

Fab

Floating action button: a big round periwinkle “+” that rotates into “×” and fans out labelled pastel speed-dial actions with a staggered bounce; closes on Escape or outside click.

soft-flat/ui/fab
Open ↗

Source

"use client";

import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";

export interface FabAction {
  label: string;
  icon: ReactNode;
  tone?: "peach" | "mint" | "lavender" | "butter";
  onSelect?: () => void;
}

export interface FabProps {
  label?: string;
  actions?: FabAction[];
  /** fixed = pinned bottom-right of the viewport; static = in flow. */
  position?: "fixed" | "static";
  className?: string;
}

const TONES = {
  peach: "bg-da-accent text-da-accent-fg",
  mint: "bg-da-secondary text-da-secondary-fg",
  lavender: "bg-da-primary/15 text-da-fg",
  butter: "bg-da-warning/35 text-da-fg",
} as const;

/** Floating action button: a big round periwinkle “+” that rotates into “×” and fans out labelled pastel speed-dial actions with a staggered bounce. */
export function Fab({
  label = "Create",
  actions = [
    { label: "New task", icon: "✅", tone: "mint" },
    { label: "New board", icon: "🧩", tone: "lavender" },
    { label: "Reminder", icon: "🔔", tone: "butter" },
    { label: "Note", icon: "📝", tone: "peach" },
  ],
  position = "fixed",
  className,
}: FabProps) {
  const [open, setOpen] = useState(false);
  const id = useId();
  const root = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
    const onDown = (e: MouseEvent) => root.current && !root.current.contains(e.target as Node) && setOpen(false);
    window.addEventListener("keydown", onKey);
    document.addEventListener("mousedown", onDown);
    return () => {
      window.removeEventListener("keydown", onKey);
      document.removeEventListener("mousedown", onDown);
    };
  }, [open]);

  return (
    <div ref={root} className={cn(position === "fixed" ? "fixed right-6 bottom-6 z-40" : "relative", "flex flex-col items-end gap-3", className)}>
      {open && (
        <ul id={id} className="flex flex-col items-end gap-2.5">
          {actions.map((a, i) => (
            <li
              key={a.label}
              className="transition-[opacity,translate,scale] duration-(--da-duration) ease-da-emphasized starting:translate-y-3 starting:scale-75 starting:opacity-0"
              style={{ transitionDelay: `${(actions.length - 1 - i) * 40}ms` }}
            >
              <button
                type="button"
                onClick={() => {
                  a.onSelect?.();
                  setOpen(false);
                }}
                className="da-focus group flex items-center gap-3 rounded-da-pill"
              >
                <span className="rounded-da-pill bg-da-surface px-3 py-1.5 text-sm font-semibold shadow-da-md">{a.label}</span>
                <span
                  aria-hidden
                  className={cn(
                    "grid size-12 place-items-center rounded-full text-xl shadow-da-md transition-[scale] duration-(--da-duration) group-hover:scale-110",
                    TONES[a.tone ?? "lavender"],
                  )}
                >
                  {a.icon}
                </span>
              </button>
            </li>
          ))}
        </ul>
      )}
      <button
        type="button"
        aria-expanded={open}
        aria-controls={id}
        aria-label={open ? "Close menu" : label}
        onClick={() => setOpen((o) => !o)}
        className="da-focus grid size-16 place-items-center rounded-full bg-da-primary text-da-primary-fg shadow-da-lg transition-[scale] duration-(--da-duration) ease-da-emphasized hover:scale-105 active:scale-95"
      >
        <Plus aria-hidden className={cn("size-7 transition-transform duration-(--da-duration) ease-da-emphasized", open && "rotate-45")} />
      </button>
    </div>
  );
}

export default Fab;

modules/soft-flat/ui/fab/index.tsx

Props

PropTypeDefaultDescription
labelstring"Create"Accessible name.
actions{ label, icon, tone?, onSelect? }[]—Speed-dial items.
position"fixed" | "static""fixed"Placement.
classNamestring—Classes.

Other button variants in Soft Flat

Button in other art directions

Button

Pressable button with 6 variants (primary, secondary, ink, outline, ghost, danger), 4 sizes, icons, loading state and link mode (href). Hard shadow collapses on hover/press.

BrutalistButton

Copy Button

Code field with an attached yellow copy button that flips to green 'Copied' with a check; optional secret masking for API keys and custom display text for commands.

BrutalistButton

Segmented Control

Ruled segmented control for mutually exclusive options: mono uppercase segments split by strokes, ink selected segment, yellow hover. Controlled or uncontrolled, typed values.

BrutalistButton

Split Button

Yellow primary action glued to a chevron trigger that opens a ruled dropdown of secondary actions with descriptions (Radix DropdownMenu). Both halves flip to ink on hover.

BrutalistButton

Button

Pill button with 5 variants (primary→accent gradient, frosted glass, solid ink, ghost, danger), 4 sizes, icons, loading state and link mode. Lifts on hover with a slight overshoot.

GlassButton

Icon Button

Round frosted icon button with a tooltip; optional toggle mode (aria-pressed) that swaps the icon and fills with the gradient or danger color — ideal for call controls.

GlassButton

Record Button

Record toggle pill: red dot inside a soft halo that morphs into a stop square with a pulsing ring and a running mm:ss timer while recording.

GlassButton

Shine Button

Gradient pill button with a glossy top highlight and a bright light sweep crossing it on hover or keyboard focus (CSS only); renders an anchor when href is set. Two sizes.

GlassButton

Button

Compact button with 6 variants (indigo primary with inner highlight, ink secondary, outline, ghost, danger, link), 4 sizes, icons, keyboard-shortcut hint, loading state and link mode.

MinimalButton

Loading Button

Async action button that morphs through pending (spinner), success (green check) and error (red cross) states, then resets; all labels share one grid cell so the width never jumps.

MinimalButton

Shortcut Button

Button displaying its keyboard shortcut as keycaps; pressing the shortcut anywhere on the page clicks it (ignored while typing in a field). Primary, secondary and ghost styles.

MinimalButton

Toggle Group

Single-choice segmented toolbar on a muted track with a raised active segment (Radix ToggleGroup): text, icon-only or mixed options, two sizes, controlled or uncontrolled.

MinimalButton

Arrow Link

Text link with an ↗ that nudges up-right on hover and an ink underline that wipes in from the left; optional mono index prefix; scales from inline to display size.

Mono CleanButton

Button

Square monochrome button: ink primary, outlined (inverts on hover), ghost and underline (signal-colored underline) variants; three sizes and an optional trailing glyph.

Mono CleanButton

Icon Button

Square icon-only button with a hairline border (darkens on hover), ghost or solid ink variant; required label for aria-label and title.

Mono CleanButton

Text Toggle

Typographic toggle: options are plain mono words separated by slashes; the active one is ink with a signal underline, others muted — radio semantics with arrow keys.

Mono CleanButton

Button

Corporate button: 6px radius, five variants (blue primary, bordered white secondary, ghost, danger, link), three sizes, leading/trailing icons.

Neo CorporateButton

Button Group

Attached segmented button group with shared borders (radio semantics) — for period pickers and view switchers. Selected segment gets a surface-2 fill and bold label.

Neo CorporateButton

Icon Button

Square icon-only button (secondary, ghost, primary, danger) with required aria-label, title tooltip and optional count/dot badge.

Neo CorporateButton

Loading Button

Async action button: idle → spinner + “Processing…” (aria-busy, width locked) → green check “Sent” for 2s → idle. Default simulates a 1.4s payment run.

Neo CorporateButton

Arrow Button

Link-button with a round arrow badge at its end: on hover the badge grows and the arrow turns from ↗ to →, a signature call-to-action for the DA.

Organic SoftButton

Button

Soft pill button: sage primary, sage-tint soft, terracotta clay, outlined ink and ghost variants; three sizes, icons, gentle hover.

Organic SoftButton

Icon Button

Round icon-only button in soft sage, sage, cream (with shadow) or ghost; required label used for aria-label and title.

Organic SoftButton

Segmented

Pill segmented control on a sand track: the selected pill is cream with a soft shadow and slides with a sliding indicator; radio semantics with arrow keys.

Organic SoftButton