FloatButton

Keep a common action at the edge of a page or container.

Workspace notes

Weekly design review

Next release checklist

<script setup lang="ts">
  import { ref } from 'vue'
  import { Plus } from '@lucide/vue'
  import {
    Button,
    Card,
    Dialog,
    FloatButton,
    FormField,
    Heading,
    Input,
    Stack,
    Text,
  } from '@hina-ui/vue'

  const open = ref(false)
  const title = ref('')
  const notes = ref(['Weekly design review', 'Next release checklist'])
  function create() {
    if (!title.value.trim()) return
    notes.value.push(title.value.trim())
    title.value = ''
    open.value = false
  }
</script>

<template>
  <Card class="relative min-h-72 w-full max-w-md pb-24">
    <Stack gap="lg">
      <Heading :level="3" size="base">Workspace notes</Heading>
      <Stack gap="sm">
        <Text v-for="(note, index) in notes" :key="index" class="border-line border-b pb-3">
          {{ note }}
        </Text>
      </Stack>
    </Stack>
    <FloatButton position="absolute" label="New note" @click="open = true"><Plus /></FloatButton>
    <Dialog v-model:open="open" title="New note" description="Give your new note a title.">
      <template #content>
        <FormField label="Note title">
          <Input
            v-model="title"
            placeholder="For example: interaction details"
            @keydown.enter.prevent="create"
          />
        </FormField>
      </template>
      <template #footer>
        <Button variant="soft" tone="neutral" @click="open = false">Cancel</Button>
        <Button :disabled="!title.trim()" @click="create">Create</Button>
      </template>
    </Dialog>
  </Card>
</template>
vue

Usage

import { FloatButton } from '@hina-ui/vue'
ts

The button defaults to the logical bottom-end corner of the viewport. Provide an action name through label and an icon through the default slot. Use it for common actions such as creating content or opening help. Keep local actions in regular Button or IconButton controls.

<FloatButton label="New note" @click="createNote"><Plus /></FloatButton>
vue

For a page-level action, mount it near the outer application shell so transformed ancestors do not change its fixed-position containing block. The component stays in place rather than teleporting to body, preserving its local theme, direction, and component context. The example above uses position="absolute" to keep the action inside its preview panel.

Examples

Appearance

Choose solid, soft, or outline styling. shape="square" uses a rounded square. Icon buttons reuse Tooltip when a TooltipProvider is available; AppShell already includes one. The accessible label is always present, independently of the tooltip.

Hover or focus with the keyboard to see each action name

<script setup lang="ts">
  import { ref } from 'vue'
  import { HelpCircle, Pencil, Plus } from '@lucide/vue'
  import { FloatButton, Flex, Stack, Text } from '@hina-ui/vue'

  const result = ref('Hover or focus with the keyboard to see each action name')
</script>

<template>
  <Stack align="center" gap="lg">
    <Flex wrap justify="center" gap="lg">
      <FloatButton
        position="static"
        label="Create project"
        @click="result = 'Selected: Create project'"
      >
        <Plus />
      </FloatButton>
      <FloatButton
        position="static"
        label="Edit project"
        variant="soft"
        shape="square"
        @click="result = 'Selected: Edit project'"
      >
        <Pencil />
      </FloatButton>
      <FloatButton
        position="static"
        label="Help center"
        variant="outline"
        tone="neutral"
        @click="result = 'Selected: Help center'"
      >
        <HelpCircle />
      </FloatButton>
    </Flex>
    <Text role="status" size="sm" tone="muted">{{ result }}</Text>
  </Stack>
</template>
vue

Size and visible labels

sm, md, and lg have default heights of 40, 48, and 56px. extended displays the label beside the icon and suppresses the duplicate tooltip. Long labels truncate within the available width.

Extended buttons keep the same height and grow to fit the label

<script setup lang="ts">
  import { ref } from 'vue'
  import { Plus } from '@lucide/vue'
  import { FloatButton, Flex, Stack, Text } from '@hina-ui/vue'

  const result = ref('Extended buttons keep the same height and grow to fit the label')
</script>

<template>
  <Stack align="center" gap="lg">
    <Flex wrap align="center" justify="center" gap="lg">
      <FloatButton
        v-for="size in ['sm', 'md', 'lg'] as const"
        :key="size"
        position="static"
        :size="size"
        :label="`Create project(${size})`"
        @click="result = `Selected: ${size}`"
      >
        <Plus />
      </FloatButton>
      <FloatButton
        position="static"
        extended
        label="Create project"
        @click="result = 'Selected: Create project'"
      >
        <Plus />
      </FloatButton>
    </Flex>
    <Text role="status" size="sm" tone="muted">{{ result }}</Text>
  </Stack>
</template>
vue

Position and direction

placement uses logical directions: start and end swap in RTL. absolute positions relative to the nearest positioned ancestor. static participates in normal layout, allowing composition with Stack. offset sets the edge spacing, and { x, y } sets the horizontal and vertical spacing separately; fixed positioning also respects device safe areas.

Start and end follow the writing direction

<script setup lang="ts">
  import { ref } from 'vue'
  import { Plus } from '@lucide/vue'
  import { Card, Center, FloatButton, FormField, Stack, Switch, Text } from '@hina-ui/vue'

  const rtl = ref(false)
  const result = ref('Start and end follow the writing direction')
  const placements = ['top-start', 'top-end', 'bottom-start', 'bottom-end'] as const
</script>

<template>
  <Stack class="w-full max-w-md">
    <FormField label="Right to left" orientation="horizontal"><Switch v-model="rtl" /></FormField>
    <Card :dir="rtl ? 'rtl' : 'ltr'" class="relative h-64" :padded="false">
      <Center class="h-full px-16">
        <Text role="status" size="sm" tone="muted" class="text-center">{{ result }}</Text>
      </Center>
      <FloatButton
        v-for="placement in placements"
        :key="placement"
        position="absolute"
        :placement="placement"
        :label="placement"
        :offset="16"
        variant="soft"
        size="sm"
        @click="result = placement"
      >
        <Plus />
      </FloatButton>
    </Card>
  </Stack>
</template>
vue

States and visibility

visible controls enter and exit transitions. An exiting button is inert. loading keeps its dimensions and prevents repeated actions, while disabled makes it unavailable.

Visibility changes include a transition

<script setup lang="ts">
  import { ref } from 'vue'
  import { RefreshCw } from '@lucide/vue'
  import { Center, FloatButton, FormField, Stack, Switch, Text } from '@hina-ui/vue'

  const visible = ref(true)
  const loading = ref(false)
  const disabled = ref(false)
  const result = ref('Visibility changes include a transition')
</script>

<template>
  <Stack class="w-full max-w-xs">
    <FormField label="Show button" orientation="horizontal"><Switch v-model="visible" /></FormField>
    <FormField label="Loading" orientation="horizontal"><Switch v-model="loading" /></FormField>
    <FormField label="Disabled" orientation="horizontal"><Switch v-model="disabled" /></FormField>
    <Center class="h-24">
      <FloatButton
        position="static"
        label="Sync profile"
        :visible="visible"
        :loading="loading"
        :disabled="disabled"
        @click="result = 'Action: Sync profile'"
      >
        <RefreshCw />
      </FloatButton>
    </Center>
    <Text role="status" size="sm" tone="muted" class="text-center">{{ result }}</Text>
  </Stack>
</template>
vue

API

Props

Prop
Type
Default
Description
label
string
Required
Accessible action name and tooltip text
visible
boolean
true
Show the button with visibility transitions
position
'fixed' | 'absolute' | 'static'
'fixed'
Positioning mode
placement
'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'
'bottom-end'
Corner; ignored in static layout
offset
number | string | { x?: number | string; y?: number | string }
6 × spacing
Edge spacing; numbers are pixels. The object form sets horizontal x and vertical y separately, and an omitted axis uses the default. Fixed positioning uses the greater of spacing and safe-area inset
size
'sm' | 'md' | 'lg'
'md'
Button size
shape
'circle' | 'square'
'circle'
Circular or rounded-square shape
extended
boolean
false
Show the label beside the icon
variant
'solid' | 'soft' | 'outline'
'solid'
Appearance
tone
'accent' | 'neutral' | 'danger'
'accent'
Color tone
tooltip
boolean
true
Show a tooltip in icon-only mode
tooltipSide
'top' | 'right' | 'bottom' | 'left'
'top'
Preferred tooltip side
loading
boolean
false
Show progress and block activation
disabled
boolean
false
Disable the action
ripple
boolean
true
Ripple feedback
as
string | Component
'button'
Action element or component
type
'button' | 'submit' | 'reset'
'button'
Native button type
class
string
—
Button classes
style
StyleValue
—
Button styles

Native attributes and listeners, such as id, form, and @click, pass through to the button.

Slots

Slot
Description
default
Action icon; displayed before the label when extended

Expose

Name
Type
Description
element
HTMLElement | undefined
Current action element
focus
() => void
Focus the enabled button