# FAB

A floating action button pinned to the bottom corner, with an optional label and speed-dial actions.

FAB is an open-source component from suiss/ui for React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/fab

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-fab--default.light.webp)

```tsx
import { View } from "react-native"
import { Fab } from "@/components/ui/fab"
import { Text } from "@/components/ui/text"

function Page({ log }: { log?: string }) {
  return (
    <View className="gap-2">
      <Text variant="h3">Notes</Text>
      <Text variant="muted">
        The button floats at the bottom end of the screen.
      </Text>
      {log ? (
        <Text role="status" className="text-sm text-muted-foreground">
          {log}
        </Text>
      ) : null}
    </View>
  )
}

export function FabExample() {
  return (
    <View className="flex-1">
      <Page />
      <Fab accessibilityLabel="New note" />
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct FabExample: View {
  var body: some View {
    FabStage {
      Fab(accessibilityLabel: "New note", action: {}) { Ph.plus.bold }
    }
  }
}

struct FabPage: View {
  var log: String?

  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      Text("Notes")
        .textVariant(.h3)
      Text("The button floats at the bottom end of the screen.")
        .textVariant(.muted)
      if let log, !log.isEmpty {
        Text(verbatim: log)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct FabStage<Content: View>: View {
  var log: String?
  @ViewBuilder let content: () -> Content

  var body: some View {
    ZStack(alignment: .topLeading) {
      FabPage(log: log)
      content()
    }
    .containerRelativeFrame(.vertical) { height, _ in max(height - 32, 0) }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Plus
import com.esuiss.ui.components.Fab
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun FabExample() {
    FabPage {
        Fab(PhosphorIcons.Regular.Plus, contentDescription = "New note", onClick = {})
    }
}

@Composable
fun FabPage(log: String? = null, fab: @Composable () -> Unit) {
    Box(Modifier.fillMaxSize()) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .windowInsetsPadding(WindowInsets.safeDrawing)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text("Notes", variant = TextVariant.H3)
            Text("The button floats at the bottom end of the screen.", variant = TextVariant.Muted)
            if (!log.isNullOrEmpty()) {
                Text(
                    log,
                    modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                    style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
                )
            }
        }
        fab()
    }
}
```

`Fab` is a round primary `Pressable` rendered through a portal and pinned to the bottom-end corner of the screen, above the safe-area insets. It comes in `default` and `lg` sizes, and becomes an extended pill when you pass a `label`.

When it has `FabAction` children, pressing it opens a speed dial: a scrim dims the screen and the actions rise above the button one after another. The plus icon rotates 45 degrees into a close mark; other icons swap to an X.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/fab
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add fab
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add fab --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add fab --platform kotlin
```

The CLI also adds what it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/hooks.tsx`, `components/primitives/portal.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/fab.tsx`:

```tsx title="uniwind/components/ui/fab.tsx"
import { cn } from "cn"
import {
  type Icon as PhosphorIcon,
  PlusIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import { I18nManager, Pressable, View } from "react-native"
import {
  Easing,
  FadeIn,
  FadeInDown,
  FadeOut,
  FadeOutDown,
  ReduceMotion,
  useAnimatedStyle,
  withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useBackHandler } from "@/components/primitives/hooks"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type FabSize = "default" | "lg"

const FabContext = React.createContext<{ close: () => void } | null>(null)

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const FAB_SIZE = {
  lg: { extended: "h-16 gap-2.5 px-6", compact: "size-16" },
  default: { extended: "h-14 gap-2 px-5", compact: "size-14" },
}

function isAction(node: React.ReactNode) {
  return React.isValidElement(node) && node.type === FabAction
}

function Fab({
  size = "default",
  icon = PlusIcon,
  label,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  portalHost,
  className,
  children,
  onPress,
  accessibilityState,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children"> & {
  size?: FabSize
  icon?: PhosphorIcon
  label?: React.ReactNode
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  portalHost?: string
  children?: React.ReactNode
}) {
  const insets = useSafeAreaInsets()
  const id = React.useId()
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
  const actions = React.Children.toArray(children).filter(isAction)
  const hasActions = actions.length > 0
  const open = hasActions && (openProp ?? uncontrolledOpen)
  const extended = label !== undefined && label !== null

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setUncontrolledOpen(next)
      onOpenChange?.(next)
    },
    [openProp, onOpenChange]
  )

  const close = React.useCallback(() => setOpen(false), [setOpen])

  useBackHandler(open, close)

  const rotated = open && icon === PlusIcon

  const rotateStyle = useAnimatedStyle(() => ({
    transform: [
      {
        rotate: withTiming(rotated ? "45deg" : "0deg", {
          duration: 250,
          easing: EASING,
          reduceMotion: ReduceMotion.System,
        }),
      },
    ],
  }))

  const context = React.useMemo(() => ({ close }), [close])

  return (
    <Portal name={`fab-${id}`} hostName={portalHost}>
      <FabContext.Provider value={context}>
        {open ? (
          <AnimatedView
            as="Pressable"
            accessible={false}
            importantForAccessibility="no"
            entering={FadeIn.duration(200).reduceMotion(ReduceMotion.System)}
            exiting={FadeOut.duration(200).reduceMotion(ReduceMotion.System)}
            className="absolute inset-0 z-40 bg-black/50"
            onPress={close}
          />
        ) : null}
        <View
          className="absolute z-40 items-end"
          style={{
            bottom: insets.bottom + 16,
            end: (I18nManager.isRTL ? insets.left : insets.right) + 16,
            pointerEvents: "box-none",
          }}
        >
          <View
            className={cn("items-end gap-3", open && "mb-4")}
            onAccessibilityEscape={close}
            style={{ pointerEvents: "box-none" }}
          >
            {open
              ? actions.map((action, index) => (
                  <AnimatedView
                    key={React.isValidElement(action) ? action.key : index}
                    entering={FadeInDown.delay(
                      (actions.length - 1 - index) * 30
                    )
                      .duration(250)
                      .reduceMotion(ReduceMotion.System)}
                    exiting={FadeOutDown.duration(150).reduceMotion(
                      ReduceMotion.System
                    )}
                  >
                    {action}
                  </AnimatedView>
                ))
              : null}
          </View>
          <Pressable
            role="button"
            accessibilityState={
              hasActions
                ? { ...accessibilityState, expanded: open }
                : accessibilityState
            }
            onPress={(event) => {
              if (hasActions) setOpen(!open)
              onPress?.(event)
            }}
            className={cn(
              "flex-row items-center justify-center rounded-full bg-primary shadow-lg active:opacity-80",
              FAB_SIZE[size === "lg" ? "lg" : "default"][
                extended ? "extended" : "compact"
              ],
              className
            )}
            {...props}
          >
            <AnimatedView style={rotateStyle}>
              <Icon
                as={open && icon !== PlusIcon ? XIcon : icon}
                weight="bold"
                className={cn(
                  "text-primary-foreground",
                  size === "lg" ? "size-7" : "size-6"
                )}
              />
            </AnimatedView>
            {extended &&
              (typeof label === "string" || typeof label === "number" ? (
                <Text className="text-base font-semibold text-primary-foreground">
                  {label}
                </Text>
              ) : (
                label
              ))}
          </Pressable>
        </View>
      </FabContext.Provider>
    </Portal>
  )
}

function FabAction({
  icon,
  className,
  children,
  onPress,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children"> & {
  icon: PhosphorIcon
  children?: React.ReactNode
}) {
  const fab = React.useContext(FabContext)
  return (
    <Pressable
      role="button"
      className={cn("flex-row items-center gap-3 active:opacity-80", className)}
      onPress={(event) => {
        onPress?.(event)
        fab?.close()
      }}
      {...props}
    >
      {children !== undefined ? (
        <View className="rounded-full bg-card px-3 py-1.5 shadow-md dark:bg-muted">
          {typeof children === "string" || typeof children === "number" ? (
            <Text numberOfLines={1} className="text-sm font-medium">
              {children}
            </Text>
          ) : (
            children
          )}
        </View>
      ) : null}
      <View className="size-11 items-center justify-center rounded-full bg-card shadow-md dark:bg-muted">
        <Icon as={icon} className="size-5" />
      </View>
    </Pressable>
  )
}

export type { FabSize }
export { Fab, FabAction }
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/hooks.tsx`, `components/primitives/portal.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/fab.tsx`:

```tsx title="nativewind/components/ui/fab.tsx"
import { cn } from "cn"
import {
  type Icon as PhosphorIcon,
  PlusIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import { I18nManager, Pressable, View } from "react-native"
import {
  Easing,
  FadeIn,
  FadeInDown,
  FadeOut,
  FadeOutDown,
  ReduceMotion,
  useAnimatedStyle,
  withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useBackHandler } from "@/components/primitives/hooks"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type FabSize = "default" | "lg"

const FabContext = React.createContext<{ close: () => void } | null>(null)

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const FAB_SIZE = {
  lg: { extended: "h-16 gap-2.5 px-6", compact: "size-16" },
  default: { extended: "h-14 gap-2 px-5", compact: "size-14" },
}

function isAction(node: React.ReactNode) {
  return React.isValidElement(node) && node.type === FabAction
}

function Fab({
  size = "default",
  icon = PlusIcon,
  label,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  portalHost,
  className,
  children,
  onPress,
  accessibilityState,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children"> & {
  size?: FabSize
  icon?: PhosphorIcon
  label?: React.ReactNode
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  portalHost?: string
  children?: React.ReactNode
}) {
  const insets = useSafeAreaInsets()
  const id = React.useId()
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
  const actions = React.Children.toArray(children).filter(isAction)
  const hasActions = actions.length > 0
  const open = hasActions && (openProp ?? uncontrolledOpen)
  const extended = label !== undefined && label !== null

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setUncontrolledOpen(next)
      onOpenChange?.(next)
    },
    [openProp, onOpenChange]
  )

  const close = React.useCallback(() => setOpen(false), [setOpen])

  useBackHandler(open, close)

  const rotated = open && icon === PlusIcon

  const rotateStyle = useAnimatedStyle(() => ({
    transform: [
      {
        rotate: withTiming(rotated ? "45deg" : "0deg", {
          duration: 250,
          easing: EASING,
          reduceMotion: ReduceMotion.System,
        }),
      },
    ],
  }))

  const context = React.useMemo(() => ({ close }), [close])

  return (
    <Portal name={`fab-${id}`} hostName={portalHost}>
      <FabContext.Provider value={context}>
        {open ? (
          <AnimatedView
            as="Pressable"
            accessible={false}
            importantForAccessibility="no"
            entering={FadeIn.duration(200).reduceMotion(ReduceMotion.System)}
            exiting={FadeOut.duration(200).reduceMotion(ReduceMotion.System)}
            className="absolute inset-0 z-40 bg-black/50"
            onPress={close}
          />
        ) : null}
        <View
          className="absolute z-40 items-end"
          style={{
            bottom: insets.bottom + 16,
            end: (I18nManager.isRTL ? insets.left : insets.right) + 16,
            pointerEvents: "box-none",
          }}
        >
          <View
            className={cn("items-end gap-3", open && "mb-4")}
            onAccessibilityEscape={close}
            style={{ pointerEvents: "box-none" }}
          >
            {open
              ? actions.map((action, index) => (
                  <AnimatedView
                    key={React.isValidElement(action) ? action.key : index}
                    entering={FadeInDown.delay(
                      (actions.length - 1 - index) * 30
                    )
                      .duration(250)
                      .reduceMotion(ReduceMotion.System)}
                    exiting={FadeOutDown.duration(150).reduceMotion(
                      ReduceMotion.System
                    )}
                  >
                    {action}
                  </AnimatedView>
                ))
              : null}
          </View>
          <Pressable
            role="button"
            accessibilityState={
              hasActions
                ? { ...accessibilityState, expanded: open }
                : accessibilityState
            }
            onPress={(event) => {
              if (hasActions) setOpen(!open)
              onPress?.(event)
            }}
            className={cn(
              "flex-row items-center justify-center rounded-full bg-primary shadow-lg active:opacity-80",
              FAB_SIZE[size === "lg" ? "lg" : "default"][
                extended ? "extended" : "compact"
              ],
              className
            )}
            {...props}
          >
            <AnimatedView style={rotateStyle}>
              <Icon
                as={open && icon !== PlusIcon ? XIcon : icon}
                weight="bold"
                className={cn(
                  "text-primary-foreground",
                  size === "lg" ? "size-7" : "size-6"
                )}
              />
            </AnimatedView>
            {extended &&
              (typeof label === "string" || typeof label === "number" ? (
                <Text className="text-base font-semibold text-primary-foreground">
                  {label}
                </Text>
              ) : (
                label
              ))}
          </Pressable>
        </View>
      </FabContext.Provider>
    </Portal>
  )
}

function FabAction({
  icon,
  className,
  children,
  onPress,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children"> & {
  icon: PhosphorIcon
  children?: React.ReactNode
}) {
  const fab = React.useContext(FabContext)
  return (
    <Pressable
      role="button"
      className={cn("flex-row items-center gap-3 active:opacity-80", className)}
      onPress={(event) => {
        onPress?.(event)
        fab?.close()
      }}
      {...props}
    >
      {children !== undefined ? (
        <View className="rounded-full bg-card px-3 py-1.5 shadow-md dark:bg-muted">
          {typeof children === "string" || typeof children === "number" ? (
            <Text numberOfLines={1} className="text-sm font-medium">
              {children}
            </Text>
          ) : (
            children
          )}
        </View>
      ) : null}
      <View className="size-11 items-center justify-center rounded-full bg-card shadow-md dark:bg-muted">
        <Icon as={icon} className="size-5" />
      </View>
    </Pressable>
  )
}

export type { FabSize }
export { Fab, FabAction }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/hooks.tsx`, `components/primitives/portal.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/fab.tsx`:

```tsx title="stylesheet/components/ui/fab.tsx"
import {
  type Icon as PhosphorIcon,
  PlusIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import {
  I18nManager,
  Pressable,
  type StyleProp,
  StyleSheet,
  View,
  type ViewStyle,
} from "react-native"
import {
  Easing,
  FadeIn,
  FadeInDown,
  FadeOut,
  FadeOutDown,
  ReduceMotion,
  useAnimatedStyle,
  withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useBackHandler } from "@/components/primitives/hooks"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { fontSize, radius, shadow, useTheme } from "@/components/ui/theme"

type FabSize = "default" | "lg"

const FabContext = React.createContext<{ close: () => void } | null>(null)

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const styles = StyleSheet.create({
  scrim: {
    position: "absolute",
    top: 0,
    right: 0,
    bottom: 0,
    left: 0,
    zIndex: 40,
  },
  frame: { position: "absolute", zIndex: 40, alignItems: "flex-end" },
  actions: { alignItems: "flex-end", gap: 12 },
  actionsOpen: { marginBottom: 16 },
  button: {
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
    ...shadow.lg,
  },
  default: { width: 56, height: 56 },
  lg: { width: 64, height: 64 },
  defaultExtended: { height: 56, gap: 8, paddingHorizontal: 20 },
  lgExtended: { height: 64, gap: 10, paddingHorizontal: 24 },
  label: { ...fontSize.base, fontWeight: "600" },
  action: { flexDirection: "row", alignItems: "center", gap: 12 },
  pill: {
    borderRadius: radius.full,
    paddingHorizontal: 12,
    paddingVertical: 6,
    ...shadow.md,
  },
  pillText: { ...fontSize.sm, fontWeight: "500" },
  circle: {
    width: 44,
    height: 44,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
    ...shadow.md,
  },
})

function isAction(node: React.ReactNode) {
  return React.isValidElement(node) && node.type === FabAction
}

function Fab({
  size = "default",
  icon = PlusIcon,
  label,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  portalHost,
  style,
  children,
  onPress,
  accessibilityState,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children" | "style"> & {
  style?: StyleProp<ViewStyle>
  size?: FabSize
  icon?: PhosphorIcon
  label?: React.ReactNode
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  portalHost?: string
  children?: React.ReactNode
}) {
  const { colors } = useTheme()
  const insets = useSafeAreaInsets()
  const id = React.useId()
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
  const actions = React.Children.toArray(children).filter(isAction)
  const hasActions = actions.length > 0
  const open = hasActions && (openProp ?? uncontrolledOpen)
  const extended = label !== undefined && label !== null

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setUncontrolledOpen(next)
      onOpenChange?.(next)
    },
    [openProp, onOpenChange]
  )

  const close = React.useCallback(() => setOpen(false), [setOpen])

  useBackHandler(open, close)

  const rotated = open && icon === PlusIcon

  const rotateStyle = useAnimatedStyle(() => ({
    transform: [
      {
        rotate: withTiming(rotated ? "45deg" : "0deg", {
          duration: 250,
          easing: EASING,
          reduceMotion: ReduceMotion.System,
        }),
      },
    ],
  }))

  const context = React.useMemo(() => ({ close }), [close])
  const extendedKey = size === "lg" ? "lgExtended" : "defaultExtended"
  const compactKey = size === "lg" ? "lg" : "default"
  const labelNode =
    typeof label === "string" || typeof label === "number" ? (
      <Text style={[styles.label, { color: colors.primaryForeground }]}>
        {label}
      </Text>
    ) : (
      label
    )

  return (
    <Portal name={`fab-${id}`} hostName={portalHost}>
      <FabContext.Provider value={context}>
        {open ? (
          <AnimatedView
            as="Pressable"
            accessible={false}
            importantForAccessibility="no"
            entering={FadeIn.duration(200).reduceMotion(ReduceMotion.System)}
            exiting={FadeOut.duration(200).reduceMotion(ReduceMotion.System)}
            style={[styles.scrim, { backgroundColor: "rgba(0, 0, 0, 0.5)" }]}
            onPress={close}
          />
        ) : null}
        <View
          style={[
            styles.frame,
            {
              bottom: insets.bottom + 16,
              end: (I18nManager.isRTL ? insets.left : insets.right) + 16,
              pointerEvents: "box-none",
            },
          ]}
        >
          <View
            onAccessibilityEscape={close}
            style={[
              styles.actions,
              open && styles.actionsOpen,
              { pointerEvents: "box-none" },
            ]}
          >
            {open
              ? actions.map((action, index) => (
                  <AnimatedView
                    key={React.isValidElement(action) ? action.key : index}
                    entering={FadeInDown.delay(
                      (actions.length - 1 - index) * 30
                    )
                      .duration(250)
                      .reduceMotion(ReduceMotion.System)}
                    exiting={FadeOutDown.duration(150).reduceMotion(
                      ReduceMotion.System
                    )}
                  >
                    {action}
                  </AnimatedView>
                ))
              : null}
          </View>
          <Pressable
            role="button"
            accessibilityState={
              hasActions
                ? { ...accessibilityState, expanded: open }
                : accessibilityState
            }
            onPress={(event) => {
              if (hasActions) setOpen(!open)
              onPress?.(event)
            }}
            style={({ pressed }) => [
              styles.button,
              styles[extended ? extendedKey : compactKey],
              { backgroundColor: colors.primary },
              pressed && { opacity: 0.8 },
              style,
            ]}
            {...props}
          >
            <AnimatedView style={rotateStyle}>
              <Icon
                as={open && icon !== PlusIcon ? XIcon : icon}
                weight="bold"
                size={size === "lg" ? 28 : 24}
                color={colors.primaryForeground}
              />
            </AnimatedView>
            {extended ? labelNode : null}
          </Pressable>
        </View>
      </FabContext.Provider>
    </Portal>
  )
}

function FabAction({
  icon,
  style,
  children,
  onPress,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children" | "style"> & {
  icon: PhosphorIcon
  children?: React.ReactNode
  style?: StyleProp<ViewStyle>
}) {
  const { colors, isDark } = useTheme()
  const raised = {
    backgroundColor: isDark ? colors.muted : colors.card,
  }
  const fab = React.useContext(FabContext)
  return (
    <Pressable
      role="button"
      style={({ pressed }) => [
        styles.action,
        pressed && { opacity: 0.8 },
        style,
      ]}
      onPress={(event) => {
        onPress?.(event)
        fab?.close()
      }}
      {...props}
    >
      {children !== undefined ? (
        <View style={[styles.pill, raised]}>
          {typeof children === "string" || typeof children === "number" ? (
            <Text numberOfLines={1} style={styles.pillText}>
              {children}
            </Text>
          ) : (
            children
          )}
        </View>
      ) : null}
      <View style={[styles.circle, raised]}>
        <Icon as={icon} size={20} />
      </View>
    </Pressable>
  )
}

export type { FabSize }
export { Fab, FabAction }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Components/Fab.swift"
import PhosphorSwift
import SwiftUI

public enum FabSize: CaseIterable, Sendable {
  case `default`, lg

  var dimension: CGFloat {
    switch self {
    case .default: 56
    case .lg: 64
    }
  }

  var iconSize: CGFloat {
    switch self {
    case .default: 24
    case .lg: 28
    }
  }
}

struct FabContext {
  var inset: CGFloat
  var close: () -> Void
}

extension EnvironmentValues {
  @Entry var fab: FabContext?
}

public struct Fab<Icon: View, Actions: View>: View {
  let label: Text?
  let accessibility: Text?
  let size: FabSize
  let external: Binding<Bool>?
  let action: (() -> Void)?
  let plus: Bool
  let icon: Icon
  let actions: Actions

  @State private var local = false
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    _ label: LocalizedStringKey? = nil,
    size: FabSize = .default,
    isExpanded: Binding<Bool>? = nil,
    @ViewBuilder icon: () -> Icon,
    @ViewBuilder actions: () -> Actions
  ) {
    self.label = label.map { Text($0) }
    accessibility = nil
    self.size = size
    external = isExpanded
    action = nil
    plus = false
    self.icon = icon()
    self.actions = actions()
  }

  public init(
    accessibilityLabel: LocalizedStringKey,
    size: FabSize = .default,
    isExpanded: Binding<Bool>? = nil,
    @ViewBuilder icon: () -> Icon,
    @ViewBuilder actions: () -> Actions
  ) {
    label = nil
    accessibility = Text(accessibilityLabel)
    self.size = size
    external = isExpanded
    action = nil
    plus = false
    self.icon = icon()
    self.actions = actions()
  }

  private var expanded: Binding<Bool> { external ?? $local }

  private func set(_ value: Bool) {
    if reduceMotion {
      expanded.wrappedValue = value
    } else {
      withAnimation(.timingCurve(0.32, 0.72, 0, 1, duration: 0.3)) { expanded.wrappedValue = value }
    }
  }

  public var body: some View {
    let open = action == nil && expanded.wrappedValue
    ZStack(alignment: .bottomTrailing) {
      if open {
        Palette.black.opacity(0.5)
          .ignoresSafeArea()
          .contentShape(Rectangle())
          .onTapGesture { set(false) }
          .accessibilityHidden(true)
          .transition(.opacity)
      }
      VStack(alignment: .trailing, spacing: 16) {
        if open {
          VStack(alignment: .trailing, spacing: 12) {
            actions
          }
          .environment(\.fab, FabContext(inset: max(0, (size.dimension - 44) / 2)) { set(false) })
          .transition(
            reduceMotion
              ? .opacity
              : .offset(y: 24).combined(with: .scale(scale: 0.9, anchor: .bottomTrailing)).combined(with: .opacity)
          )
        }
        main(open: open)
      }
      .padding(16)
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
    .accessibilityElement(children: .contain)
    .accessibilityAction(.escape) {
      if open { set(false) }
    }
  }

  private func main(open: Bool) -> some View {
    Button {
      if let action {
        action()
      } else {
        set(!expanded.wrappedValue)
      }
    } label: {
      EmptyView()
    }
    .buttonStyle(FabButtonStyle(size: size, label: label, open: open, rotates: plus) { icon })
    .accessibilityLabel(label ?? accessibility ?? Text(open ? "Close actions" : "Show actions"))
    .accessibilityValue(action == nil ? Text(open ? "Expanded" : "Collapsed") : Text(verbatim: ""))
  }
}

extension Fab where Actions == EmptyView {
  public init(
    _ label: LocalizedStringKey,
    size: FabSize = .default,
    action: @escaping () -> Void,
    @ViewBuilder icon: () -> Icon
  ) {
    self.label = Text(label)
    accessibility = nil
    self.size = size
    external = nil
    self.action = action
    plus = false
    self.icon = icon()
    actions = EmptyView()
  }

  public init(
    accessibilityLabel: LocalizedStringKey,
    size: FabSize = .default,
    action: @escaping () -> Void,
    @ViewBuilder icon: () -> Icon
  ) {
    label = nil
    accessibility = Text(accessibilityLabel)
    self.size = size
    external = nil
    self.action = action
    plus = false
    self.icon = icon()
    actions = EmptyView()
  }
}

extension Fab where Icon == Image {
  public init(
    _ label: LocalizedStringKey? = nil,
    size: FabSize = .default,
    isExpanded: Binding<Bool>? = nil,
    @ViewBuilder actions: () -> Actions
  ) {
    self.label = label.map { Text($0) }
    accessibility = nil
    self.size = size
    external = isExpanded
    action = nil
    plus = true
    icon = Ph.plus.bold
    self.actions = actions()
  }

  public init(
    accessibilityLabel: LocalizedStringKey,
    size: FabSize = .default,
    isExpanded: Binding<Bool>? = nil,
    @ViewBuilder actions: () -> Actions
  ) {
    label = nil
    accessibility = Text(accessibilityLabel)
    self.size = size
    external = isExpanded
    action = nil
    plus = true
    icon = Ph.plus.bold
    self.actions = actions()
  }
}

private struct FabButtonStyle<Icon: View>: ButtonStyle {
  let size: FabSize
  let label: Text?
  let open: Bool
  let rotates: Bool
  @ViewBuilder let icon: () -> Icon

  @Environment(\.isEnabled) private var isEnabled

  func makeBody(configuration: Configuration) -> some View {
    let pressed = configuration.isPressed
    HStack(spacing: 8) {
      Group {
        if open && !rotates {
          Ph.x.bold
        } else {
          icon()
        }
      }
      .frame(width: size.iconSize, height: size.iconSize)
      .rotationEffect(.degrees(open && rotates ? 45 : 0))
      if let label {
        label
          .scaledFont(.base, weight: .semibold)
          .lineLimit(1)
      }
    }
    .foregroundStyle(EsuissColor.primaryForeground)
    .padding(.horizontal, label == nil ? 0 : 20)
    .frame(minWidth: size.dimension, minHeight: size.dimension, maxHeight: size.dimension)
    .background {
      Capsule()
        .fill(EsuissColor.primary)
        .shadow(isEnabled ? .lg : Shadow(color: .clear, radius: 0))
    }
    .opacity(isEnabled ? (pressed ? 0.8 : 1) : 0.5)
    .scaleEffect(pressed ? 0.96 : 1)
    .animation(.easeOut(duration: 0.15), value: pressed)
    .contentShape(Capsule())
  }
}

public struct FabAction<Icon: View>: View {
  let title: Text
  let action: () -> Void
  let icon: Icon

  @Environment(\.fab) private var fab
  @Environment(\.isEnabled) private var isEnabled

  public init(_ title: LocalizedStringKey, action: @escaping () -> Void, @ViewBuilder icon: () -> Icon) {
    self.title = Text(title)
    self.action = action
    self.icon = icon()
  }

  public init(verbatim title: String, action: @escaping () -> Void, @ViewBuilder icon: () -> Icon) {
    self.title = Text(verbatim: title)
    self.action = action
    self.icon = icon()
  }

  public var body: some View {
    Button {
      fab?.close()
      action()
    } label: {
      EmptyView()
    }
    .buttonStyle(PressReader { pressed in
      HStack(spacing: 12) {
        title
          .scaledFont(.sm, weight: .medium)
          .lineLimit(1)
          .foregroundStyle(EsuissColor.foreground)
          .padding(.horizontal, 12)
          .frame(height: 32)
          .background {
            Capsule()
              .fill(EsuissColor.card)
              .shadow(.md)
          }
        icon
          .frame(width: 20, height: 20)
          .foregroundStyle(EsuissColor.foreground)
          .frame(width: 44, height: 44)
          .background {
            Circle()
              .fill(EsuissColor.card)
              .shadow(.md)
          }
      }
      .padding(.trailing, fab?.inset ?? 0)
      .opacity(isEnabled ? (pressed ? 0.8 : 1) : 0.5)
      .contentShape(Rectangle())
    })
    .accessibilityLabel(title)
  }
}
```

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/Fab.kt"
package com.esuiss.ui.components

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Plus
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Shadows
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow

enum class FabSize(internal val size: Dp, internal val iconSize: Dp) {
    Default(56.dp, 24.dp),
    Lg(64.dp, 28.dp),
}

private val LocalFabClose = staticCompositionLocalOf<() -> Unit> { {} }

@Composable
fun Fab(
    icon: ImageVector,
    contentDescription: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    size: FabSize = FabSize.Default,
    label: String? = null,
    enabled: Boolean = true,
) {
    FabFrame(modifier) {
        FabButton(icon, contentDescription, onClick, size, label, enabled, rotation = 0f, expanded = null)
    }
}

@Composable
fun Fab(
    icon: ImageVector,
    contentDescription: String,
    open: Boolean,
    onOpenChange: (Boolean) -> Unit,
    modifier: Modifier = Modifier,
    size: FabSize = FabSize.Default,
    label: String? = null,
    enabled: Boolean = true,
    actions: @Composable ColumnScope.() -> Unit,
) {
    val transition = remember { MutableTransitionState(false) }
    transition.targetState = open
    val plus = icon == PhosphorIcons.Regular.Plus
    val rotation by animateFloatAsState(if (open && plus) 45f else 0f, tween(250, easing = FormEasing), label = "fab")
    Box(modifier = Modifier.fillMaxSize()) {
        FabScrim(open, onDismiss = { onOpenChange(false) })
        FabFrame(modifier) {
            Column(
                horizontalAlignment = Alignment.End,
                verticalArrangement = Arrangement.spacedBy(16.dp),
            ) {
                AnimatedVisibility(
                    visibleState = transition,
                    enter = fadeIn(tween(200)) + slideInVertically(tween(250, easing = FormEasing)) { it / 4 } +
                        scaleIn(tween(250, easing = FormEasing), initialScale = 0.9f),
                    exit = fadeOut(tween(150)) + slideOutVertically(tween(200, easing = FormEasing)) { it / 4 } +
                        scaleOut(tween(200, easing = FormEasing), targetScale = 0.9f),
                ) {
                    CompositionLocalProvider(LocalFabClose provides { onOpenChange(false) }) {
                        Column(
                            modifier = Modifier.padding(end = (size.size - FabActionSize) / 2),
                            horizontalAlignment = Alignment.End,
                            verticalArrangement = Arrangement.spacedBy(12.dp),
                            content = actions,
                        )
                    }
                }
                FabButton(
                    if (open && !plus) PhosphorIcons.Regular.X else icon,
                    contentDescription,
                    { onOpenChange(!open) },
                    size,
                    label,
                    enabled,
                    rotation,
                    expanded = open,
                )
            }
        }
    }
}

private val FabActionSize = 44.dp

@Composable
private fun FabScrim(open: Boolean, onDismiss: () -> Unit) {
    val currentOnDismiss by rememberUpdatedState(onDismiss)
    val color = Palette.black.copy(alpha = 0.5f)
    val alpha by animateFloatAsState(if (open) 1f else 0f, tween(200), label = "scrim")
    val container = LocalWindowInfo.current.containerSize
    var origin by remember { mutableStateOf(Offset.Zero) }
    if (alpha == 0f && !open) return
    Box(
        modifier = Modifier
            .fillMaxSize()
            .onGloballyPositioned { origin = it.positionInWindow() }
            .drawBehind {
                drawRect(
                    color = color.copy(alpha = color.alpha * alpha),
                    topLeft = -origin,
                    size = Size(container.width.toFloat(), container.height.toFloat()),
                )
            }
            .then(if (open) Modifier.pointerInput(Unit) { detectTapGestures { currentOnDismiss() } } else Modifier),
    )
}

@Composable
fun FabAction(
    icon: ImageVector,
    label: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val colors = EsuissTheme.colors
    val close = LocalFabClose.current
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Row(
        modifier = modifier
            .alpha(if (pressed) 0.8f else 1f)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button) {
                onClick()
                close()
            }
            .clearAndSetSemantics { contentDescription = label },
        horizontalArrangement = Arrangement.spacedBy(12.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        BasicText(
            label,
            modifier = Modifier
                .shadow(Shadows.md, CircleShape)
                .background(colors.card, CircleShape)
                .padding(horizontal = 12.dp, vertical = 6.dp),
            style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.Medium),
            maxLines = 1,
        )
        Box(
            modifier = Modifier
                .size(FabActionSize)
                .shadow(Shadows.md, CircleShape)
                .background(colors.card, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            Icon(icon, contentDescription = null, size = 20.dp, tint = colors.foreground)
        }
    }
}

@Composable
private fun FabFrame(modifier: Modifier, content: @Composable () -> Unit) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom + WindowInsetsSides.End))
            .padding(16.dp),
        contentAlignment = Alignment.BottomEnd,
    ) {
        Box(modifier = modifier) { content() }
    }
}

@Composable
private fun FabButton(
    icon: ImageVector,
    contentDescription: String,
    onClick: () -> Unit,
    size: FabSize,
    label: String?,
    enabled: Boolean,
    rotation: Float,
    expanded: Boolean?,
) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed && enabled) 0.95f else 1f, tween(150), label = "press")
    Row(
        modifier = Modifier
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                alpha = when {
                    !enabled -> 0.5f
                    pressed -> 0.8f
                    else -> 1f
                }
            }
            .height(size.size)
            .then(if (label == null) Modifier.size(size.size) else Modifier)
            .then(if (enabled) Modifier.shadow(Shadows.lg, CircleShape) else Modifier)
            .clip(CircleShape)
            .background(colors.primary, CircleShape)
            .clickable(
                interactionSource = interaction,
                indication = null,
                enabled = enabled,
                role = Role.Button,
                onClick = onClick,
            )
            .semantics {
                this.contentDescription = contentDescription
                if (expanded != null) stateDescription = if (expanded) "Expanded" else "Collapsed"
            }
            .padding(horizontal = if (label == null) 0.dp else 20.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(
            icon,
            contentDescription = null,
            modifier = Modifier.graphicsLayer { rotationZ = rotation },
            size = size.iconSize,
            tint = colors.primaryForeground,
        )
        if (label != null) {
            BasicText(
                label,
                style = Typography.base.copy(color = colors.primaryForeground, fontWeight = FontWeight.SemiBold),
                maxLines = 1,
            )
        }
    }
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { Fab, FabAction } from "@/components/ui/fab"
```

```tsx
<View className="flex-1">
  <Page />
  <Fab accessibilityLabel="New note" />
</View>
```

Use the default plus icon or pass any Phosphor icon to `icon`. Give an icon-only button an `accessibilityLabel`.

For a speed dial, add `FabAction` children with an `icon` and an optional text label. Pressing an action calls its `onPress` and closes the dial. Tapping the scrim or pressing the Android back button also closes it. Use `open`, `defaultOpen` and `onOpenChange` to control it.

Pass `portalHost` to render into a named portal host.

## Examples

### Large

The `lg` size with a custom icon.

![Preview](https://ui.suiss.app/previews/components-fab--large.light.webp)

```tsx
import { PencilSimpleIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Fab } from "@/components/ui/fab"
import { Text } from "@/components/ui/text"

function Page({ log }: { log?: string }) {
  return (
    <View className="gap-2">
      <Text variant="h3">Notes</Text>
      <Text variant="muted">
        The button floats at the bottom end of the screen.
      </Text>
      {log ? (
        <Text role="status" className="text-sm text-muted-foreground">
          {log}
        </Text>
      ) : null}
    </View>
  )
}

export function FabLargeExample() {
  return (
    <View className="flex-1">
      <Page />
      <Fab size="lg" icon={PencilSimpleIcon} accessibilityLabel="Compose" />
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct FabLargeExample: View {
  var body: some View {
    FabStage {
      Fab(accessibilityLabel: "Compose", size: .lg, action: {}) { Ph.pencilSimple.bold }
    }
  }
}

struct FabPage: View {
  var log: String?

  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      Text("Notes")
        .textVariant(.h3)
      Text("The button floats at the bottom end of the screen.")
        .textVariant(.muted)
      if let log, !log.isEmpty {
        Text(verbatim: log)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct FabStage<Content: View>: View {
  var log: String?
  @ViewBuilder let content: () -> Content

  var body: some View {
    ZStack(alignment: .topLeading) {
      FabPage(log: log)
      content()
    }
    .containerRelativeFrame(.vertical) { height, _ in max(height - 32, 0) }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.PencilSimple
import com.esuiss.ui.components.Fab
import com.esuiss.ui.components.FabSize
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun FabLargeExample() {
    FabPage {
        Fab(PhosphorIcons.Regular.PencilSimple, contentDescription = "Compose", onClick = {}, size = FabSize.Lg)
    }
}

@Composable
fun FabPage(log: String? = null, fab: @Composable () -> Unit) {
    Box(Modifier.fillMaxSize()) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .windowInsetsPadding(WindowInsets.safeDrawing)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text("Notes", variant = TextVariant.H3)
            Text("The button floats at the bottom end of the screen.", variant = TextVariant.Muted)
            if (!log.isNullOrEmpty()) {
                Text(
                    log,
                    modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                    style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
                )
            }
        }
        fab()
    }
}
```

### Extended

A label beside the icon.

![Preview](https://ui.suiss.app/previews/components-fab--extended.light.webp)

```tsx
import { PencilSimpleIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Fab } from "@/components/ui/fab"
import { Text } from "@/components/ui/text"

function Page({ log }: { log?: string }) {
  return (
    <View className="gap-2">
      <Text variant="h3">Notes</Text>
      <Text variant="muted">
        The button floats at the bottom end of the screen.
      </Text>
      {log ? (
        <Text role="status" className="text-sm text-muted-foreground">
          {log}
        </Text>
      ) : null}
    </View>
  )
}

export function FabExtendedExample() {
  return (
    <View className="flex-1">
      <Page />
      <Fab icon={PencilSimpleIcon} label="Compose" />
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct FabExtendedExample: View {
  var body: some View {
    FabStage {
      Fab("Compose", action: {}) { Ph.pencilSimple.bold }
    }
  }
}

struct FabPage: View {
  var log: String?

  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      Text("Notes")
        .textVariant(.h3)
      Text("The button floats at the bottom end of the screen.")
        .textVariant(.muted)
      if let log, !log.isEmpty {
        Text(verbatim: log)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct FabStage<Content: View>: View {
  var log: String?
  @ViewBuilder let content: () -> Content

  var body: some View {
    ZStack(alignment: .topLeading) {
      FabPage(log: log)
      content()
    }
    .containerRelativeFrame(.vertical) { height, _ in max(height - 32, 0) }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.PencilSimple
import com.esuiss.ui.components.Fab
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun FabExtendedExample() {
    FabPage {
        Fab(PhosphorIcons.Regular.PencilSimple, contentDescription = "Compose", onClick = {}, label = "Compose")
    }
}

@Composable
fun FabPage(log: String? = null, fab: @Composable () -> Unit) {
    Box(Modifier.fillMaxSize()) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .windowInsetsPadding(WindowInsets.safeDrawing)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text("Notes", variant = TextVariant.H3)
            Text("The button floats at the bottom end of the screen.", variant = TextVariant.Muted)
            if (!log.isNullOrEmpty()) {
                Text(
                    log,
                    modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                    style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
                )
            }
        }
        fab()
    }
}
```

### Large extended

A large extended button.

![Preview](https://ui.suiss.app/previews/components-fab--large-extended.light.webp)

```tsx
import { View } from "react-native"
import { Fab } from "@/components/ui/fab"
import { Text } from "@/components/ui/text"

function Page({ log }: { log?: string }) {
  return (
    <View className="gap-2">
      <Text variant="h3">Notes</Text>
      <Text variant="muted">
        The button floats at the bottom end of the screen.
      </Text>
      {log ? (
        <Text role="status" className="text-sm text-muted-foreground">
          {log}
        </Text>
      ) : null}
    </View>
  )
}

export function FabLargeExtendedExample() {
  return (
    <View className="flex-1">
      <Page />
      <Fab size="lg" label="New note" />
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct FabLargeExtendedExample: View {
  var body: some View {
    FabStage {
      Fab("New note", size: .lg, action: {}) { Ph.plus.bold }
    }
  }
}

struct FabPage: View {
  var log: String?

  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      Text("Notes")
        .textVariant(.h3)
      Text("The button floats at the bottom end of the screen.")
        .textVariant(.muted)
      if let log, !log.isEmpty {
        Text(verbatim: log)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct FabStage<Content: View>: View {
  var log: String?
  @ViewBuilder let content: () -> Content

  var body: some View {
    ZStack(alignment: .topLeading) {
      FabPage(log: log)
      content()
    }
    .containerRelativeFrame(.vertical) { height, _ in max(height - 32, 0) }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Plus
import com.esuiss.ui.components.Fab
import com.esuiss.ui.components.FabSize
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun FabLargeExtendedExample() {
    FabPage {
        Fab(PhosphorIcons.Regular.Plus, contentDescription = "New note", onClick = {}, size = FabSize.Lg, label = "New note")
    }
}

@Composable
fun FabPage(log: String? = null, fab: @Composable () -> Unit) {
    Box(Modifier.fillMaxSize()) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .windowInsetsPadding(WindowInsets.safeDrawing)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text("Notes", variant = TextVariant.H3)
            Text("The button floats at the bottom end of the screen.", variant = TextVariant.Muted)
            if (!log.isNullOrEmpty()) {
                Text(
                    log,
                    modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                    style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
                )
            }
        }
        fab()
    }
}
```

### Speed dial open

A speed dial that starts open.

![Preview](https://ui.suiss.app/previews/components-fab--speed-dial-open.light.webp)

```tsx
import { CameraIcon, FileTextIcon, ImageIcon, NotePencilIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Fab, FabAction } from "@/components/ui/fab"
import { Text } from "@/components/ui/text"

function Page({ log }: { log?: string }) {
  return (
    <View className="gap-2">
      <Text variant="h3">Notes</Text>
      <Text variant="muted">
        The button floats at the bottom end of the screen.
      </Text>
      {log ? (
        <Text role="status" className="text-sm text-muted-foreground">
          {log}
        </Text>
      ) : null}
    </View>
  )
}

function SpeedDial({ defaultOpen = false }: { defaultOpen?: boolean }) {
  const [log, setLog] = React.useState("")
  return (
    <View className="flex-1">
      <Page log={log} />
      <Fab accessibilityLabel="Create" defaultOpen={defaultOpen}>
        <FabAction icon={NotePencilIcon} onPress={() => setLog("New note")}>
          New note
        </FabAction>
        <FabAction icon={CameraIcon} onPress={() => setLog("Take photo")}>
          Take photo
        </FabAction>
        <FabAction icon={ImageIcon} onPress={() => setLog("Add image")}>
          Add image
        </FabAction>
        <FabAction icon={FileTextIcon} onPress={() => setLog("Scan document")}>
          Scan document
        </FabAction>
      </Fab>
    </View>
  )
}

export function FabSpeedDialOpenExample() {
  return (
    <SpeedDial defaultOpen />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct FabSpeedDialOpenExample: View {
  var body: some View {
    FabSpeedDial(open: true)
  }
}

struct FabPage: View {
  var log: String?

  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      Text("Notes")
        .textVariant(.h3)
      Text("The button floats at the bottom end of the screen.")
        .textVariant(.muted)
      if let log, !log.isEmpty {
        Text(verbatim: log)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct FabStage<Content: View>: View {
  var log: String?
  @ViewBuilder let content: () -> Content

  var body: some View {
    ZStack(alignment: .topLeading) {
      FabPage(log: log)
      content()
    }
    .containerRelativeFrame(.vertical) { height, _ in max(height - 32, 0) }
  }
}

struct FabSpeedDial: View {
  @State private var open: Bool
  @State private var log = ""

  init(open: Bool) {
    _open = State(initialValue: open)
  }

  var body: some View {
    FabStage(log: log) {
      Fab(accessibilityLabel: "Create", isExpanded: $open) {
        FabAction("New note", action: { log = "New note" }) { Ph.notePencil.regular }
        FabAction("Take photo", action: { log = "Take photo" }) { Ph.camera.regular }
        FabAction("Add image", action: { log = "Add image" }) { Ph.image.regular }
        FabAction("Scan document", action: { log = "Scan document" }) { Ph.fileText.regular }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Camera
import com.adamglin.phosphoricons.regular.FileText
import com.adamglin.phosphoricons.regular.Image
import com.adamglin.phosphoricons.regular.NotePencil
import com.adamglin.phosphoricons.regular.Plus
import com.esuiss.ui.components.Fab
import com.esuiss.ui.components.FabAction
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun FabSpeedDialOpenExample() {
    FabSpeedDial(defaultOpen = true)
}

@Composable
fun FabPage(log: String? = null, fab: @Composable () -> Unit) {
    Box(Modifier.fillMaxSize()) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .windowInsetsPadding(WindowInsets.safeDrawing)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text("Notes", variant = TextVariant.H3)
            Text("The button floats at the bottom end of the screen.", variant = TextVariant.Muted)
            if (!log.isNullOrEmpty()) {
                Text(
                    log,
                    modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                    style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
                )
            }
        }
        fab()
    }
}

@Composable
fun FabSpeedDial(defaultOpen: Boolean) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    var log by rememberSaveable { mutableStateOf("") }
    FabPage(log) {
        Fab(PhosphorIcons.Regular.Plus, contentDescription = "Create", open = open, onOpenChange = { open = it }) {
            FabAction(PhosphorIcons.Regular.NotePencil, "New note", { log = "New note" })
            FabAction(PhosphorIcons.Regular.Camera, "Take photo", { log = "Take photo" })
            FabAction(PhosphorIcons.Regular.Image, "Add image", { log = "Add image" })
            FabAction(PhosphorIcons.Regular.FileText, "Scan document", { log = "Scan document" })
        }
    }
}
```

### Speed dial closed

A speed dial that opens on press.

![Preview](https://ui.suiss.app/previews/components-fab--speed-dial-closed.light.webp)

```tsx
import { CameraIcon, FileTextIcon, ImageIcon, NotePencilIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Fab, FabAction } from "@/components/ui/fab"
import { Text } from "@/components/ui/text"

function Page({ log }: { log?: string }) {
  return (
    <View className="gap-2">
      <Text variant="h3">Notes</Text>
      <Text variant="muted">
        The button floats at the bottom end of the screen.
      </Text>
      {log ? (
        <Text role="status" className="text-sm text-muted-foreground">
          {log}
        </Text>
      ) : null}
    </View>
  )
}

function SpeedDial({ defaultOpen = false }: { defaultOpen?: boolean }) {
  const [log, setLog] = React.useState("")
  return (
    <View className="flex-1">
      <Page log={log} />
      <Fab accessibilityLabel="Create" defaultOpen={defaultOpen}>
        <FabAction icon={NotePencilIcon} onPress={() => setLog("New note")}>
          New note
        </FabAction>
        <FabAction icon={CameraIcon} onPress={() => setLog("Take photo")}>
          Take photo
        </FabAction>
        <FabAction icon={ImageIcon} onPress={() => setLog("Add image")}>
          Add image
        </FabAction>
        <FabAction icon={FileTextIcon} onPress={() => setLog("Scan document")}>
          Scan document
        </FabAction>
      </Fab>
    </View>
  )
}

export function FabSpeedDialClosedExample() {
  return (
    <SpeedDial />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct FabSpeedDialClosedExample: View {
  var body: some View {
    FabSpeedDial(open: false)
  }
}

struct FabPage: View {
  var log: String?

  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      Text("Notes")
        .textVariant(.h3)
      Text("The button floats at the bottom end of the screen.")
        .textVariant(.muted)
      if let log, !log.isEmpty {
        Text(verbatim: log)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct FabStage<Content: View>: View {
  var log: String?
  @ViewBuilder let content: () -> Content

  var body: some View {
    ZStack(alignment: .topLeading) {
      FabPage(log: log)
      content()
    }
    .containerRelativeFrame(.vertical) { height, _ in max(height - 32, 0) }
  }
}

struct FabSpeedDial: View {
  @State private var open: Bool
  @State private var log = ""

  init(open: Bool) {
    _open = State(initialValue: open)
  }

  var body: some View {
    FabStage(log: log) {
      Fab(accessibilityLabel: "Create", isExpanded: $open) {
        FabAction("New note", action: { log = "New note" }) { Ph.notePencil.regular }
        FabAction("Take photo", action: { log = "Take photo" }) { Ph.camera.regular }
        FabAction("Add image", action: { log = "Add image" }) { Ph.image.regular }
        FabAction("Scan document", action: { log = "Scan document" }) { Ph.fileText.regular }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Camera
import com.adamglin.phosphoricons.regular.FileText
import com.adamglin.phosphoricons.regular.Image
import com.adamglin.phosphoricons.regular.NotePencil
import com.adamglin.phosphoricons.regular.Plus
import com.esuiss.ui.components.Fab
import com.esuiss.ui.components.FabAction
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun FabSpeedDialClosedExample() {
    FabSpeedDial(defaultOpen = false)
}

@Composable
fun FabPage(log: String? = null, fab: @Composable () -> Unit) {
    Box(Modifier.fillMaxSize()) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .windowInsetsPadding(WindowInsets.safeDrawing)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text("Notes", variant = TextVariant.H3)
            Text("The button floats at the bottom end of the screen.", variant = TextVariant.Muted)
            if (!log.isNullOrEmpty()) {
                Text(
                    log,
                    modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                    style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
                )
            }
        }
        fab()
    }
}

@Composable
fun FabSpeedDial(defaultOpen: Boolean) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    var log by rememberSaveable { mutableStateOf("") }
    FabPage(log) {
        Fab(PhosphorIcons.Regular.Plus, contentDescription = "Create", open = open, onOpenChange = { open = it }) {
            FabAction(PhosphorIcons.Regular.NotePencil, "New note", { log = "New note" })
            FabAction(PhosphorIcons.Regular.Camera, "Take photo", { log = "Take photo" })
            FabAction(PhosphorIcons.Regular.Image, "Add image", { log = "Add image" })
            FabAction(PhosphorIcons.Regular.FileText, "Scan document", { log = "Scan document" })
        }
    }
}
```

### Controlled

A speed dial controlled with `open` and `onOpenChange`.

![Preview](https://ui.suiss.app/previews/components-fab--controlled.light.webp)

```tsx
import { CameraIcon, NotePencilIcon, PencilSimpleIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Fab, FabAction } from "@/components/ui/fab"
import { Text } from "@/components/ui/text"

function Page({ log }: { log?: string }) {
  return (
    <View className="gap-2">
      <Text variant="h3">Notes</Text>
      <Text variant="muted">
        The button floats at the bottom end of the screen.
      </Text>
      {log ? (
        <Text role="status" className="text-sm text-muted-foreground">
          {log}
        </Text>
      ) : null}
    </View>
  )
}

function ControlledDemo() {
  const [open, setOpen] = React.useState(true)
  return (
    <View className="flex-1">
      <Page log={open ? "Open" : "Closed"} />
      <Fab
        icon={PencilSimpleIcon}
        label="Create"
        open={open}
        onOpenChange={setOpen}
      >
        <FabAction icon={NotePencilIcon}>New note</FabAction>
        <FabAction icon={CameraIcon}>Take photo</FabAction>
      </Fab>
    </View>
  )
}

export function FabControlledExample() {
  return (
    <ControlledDemo />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct FabPage: View {
  var log: String?

  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      Text("Notes")
        .textVariant(.h3)
      Text("The button floats at the bottom end of the screen.")
        .textVariant(.muted)
      if let log, !log.isEmpty {
        Text(verbatim: log)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct FabStage<Content: View>: View {
  var log: String?
  @ViewBuilder let content: () -> Content

  var body: some View {
    ZStack(alignment: .topLeading) {
      FabPage(log: log)
      content()
    }
    .containerRelativeFrame(.vertical) { height, _ in max(height - 32, 0) }
  }
}

struct FabControlled: View {
  @State private var open = true

  var body: some View {
    FabStage(log: open ? "Open" : "Closed") {
      Fab("Create", isExpanded: $open) {
        Ph.pencilSimple.bold
      } actions: {
        FabAction("New note", action: {}) { Ph.notePencil.regular }
        FabAction("Take photo", action: {}) { Ph.camera.regular }
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Camera
import com.adamglin.phosphoricons.regular.NotePencil
import com.adamglin.phosphoricons.regular.PencilSimple
import com.esuiss.ui.components.Fab
import com.esuiss.ui.components.FabAction
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun FabPage(log: String? = null, fab: @Composable () -> Unit) {
    Box(Modifier.fillMaxSize()) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .windowInsetsPadding(WindowInsets.safeDrawing)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text("Notes", variant = TextVariant.H3)
            Text("The button floats at the bottom end of the screen.", variant = TextVariant.Muted)
            if (!log.isNullOrEmpty()) {
                Text(
                    log,
                    modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                    style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
                )
            }
        }
        fab()
    }
}

@Composable
fun FabControlled() {
    var open by rememberSaveable { mutableStateOf(true) }
    FabPage(if (open) "Open" else "Closed") {
        Fab(
            PhosphorIcons.Regular.PencilSimple,
            contentDescription = "Create",
            open = open,
            onOpenChange = { open = it },
            label = "Create",
        ) {
            FabAction(PhosphorIcons.Regular.NotePencil, "New note", {})
            FabAction(PhosphorIcons.Regular.Camera, "Take photo", {})
        }
    }
}
```

## API Reference

### Fab

Floating button that also hosts the speed dial.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `FabSize` | `"default"` | `default` or `lg`. |
| `icon` | `PhosphorIcon` | `PlusIcon` | Phosphor icon component. Defaults to a plus. |
| `label` | `React.ReactNode` | - | Text or node shown beside the icon. |
| `open` | `boolean` | - | Controlled speed dial state. |
| `defaultOpen` | `boolean` | `false` | Initial speed dial state. |
| `onOpenChange` | `(open: boolean) => void` | - | Called when the speed dial opens or closes. |
| `portalHost` | `string` | - | Name of the portal host to render into. |
| `children` | `React.ReactNode` | - |  |

Also accepts the props of `React.ComponentProps<typeof Pressable>`.

### FabAction

One action in the speed dial, with an icon and optional label.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `icon` | `PhosphorIcon` | Required | Phosphor icon component for the action. |
| `children` | `React.ReactNode` | - |  |

Also accepts the props of `React.ComponentProps<typeof Pressable>`.

## Accessibility

- The button and each action have the `button` role.
- With actions, the button reports its `expanded` state.
- The VoiceOver escape gesture and the Android back button close the speed dial.
- The scrim is hidden from screen readers.

## Notes

- Position follows the safe-area insets and flips sides in right-to-left layouts.
- Animations respect the system Reduce Motion setting.
- Uses `bg-primary`, `bg-card` and `bg-black/50` tokens; actions use `bg-muted` in dark mode.

