FAB
A round button floating over the corner of the screen for the main thing to do there; it can open into a short stack of related actions.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add fabpnpm dlx ermineui add fabyarn dlx ermineui add fabbunx ermineui add fabThat copies 4 files into src/components/ui/fab/, 2 helpers into src/lib/ermine/. Files you already have are kept.
Use
import { Fab, FabAction } from '@/components/ui/fab'<AppBar title="Orders" back @back="router.back()">
<template #end><button class="er-app-bar-button" aria-label="Search"><Icon name="search" /></button></template>
</AppBar>
<Fab label="New"><template #actions><FabAction @select="newOrder">New order</FabAction></template></Fab>
<ActionSheet title="Wool throw">
<template #trigger><Button>Options</Button></template>
<ActionSheetItem @select="share">Share</ActionSheetItem>
<ActionSheetItem destructive @select="remove">Remove</ActionSheetItem>
</ActionSheet>
<SwipeActions>
<div class="row">…</div>
<template #actions><button data-destructive @click="remove">Delete</button></template>
</SwipeActions>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
Fab.vue | src/components/ui/fab/Fab.vue |
FabAction.vue | src/components/ui/fab/FabAction.vue |
context.ts | src/components/ui/fab/context.ts |
index.ts | src/components/ui/fab/index.ts |
context.ts helper | src/lib/ermine/context.ts |
layer.ts helper | src/lib/ermine/layer.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
Fab.vue 231 lines
<script setup lang="ts">
import { ref, useId, useSlots } from 'vue'
import { useLayer } from '../../lib/layer'
import { provideFab } from './context'
withDefaults(
defineProps<{
/** What the button does: "New message". Its accessible name, and the words shown with `extended`. */
label: string
/** Show the label beside the icon, as a pill. */
extended?: boolean
/** Float over the page in a corner. Turn off to place it yourself. */
fixed?: boolean
position?: 'bottom-right' | 'bottom-left' | 'bottom-center'
}>(),
{ fixed: true, position: 'bottom-right' },
)
const emit = defineEmits<{
/** The button was pressed and it has no actions to open. */
click: [event: MouseEvent]
}>()
/** Whether the stack of actions is showing. */
const open = defineModel<boolean>('open', { default: false })
const slots = useSlots()
const root = ref<HTMLElement | null>(null)
const menu = ref<HTMLElement | null>(null)
const menuId = useId()
provideFab({ close: () => (open.value = false) })
useLayer(menu, open, {
onDismiss: () => (open.value = false),
ignore: () => [root.value],
loopFocus: true,
})
function press(event: MouseEvent) {
if (slots.actions) open.value = !open.value
else emit('click', event)
}
</script>
<template>
<div ref="root" class="er-fab" :data-fixed="fixed ? '' : undefined" :data-position="position" :data-state="open ? 'open' : 'closed'">
<!-- FabAction, one per action. Filling this slot turns the button into one that opens them. -->
<Transition name="er-fab-actions">
<div v-if="$slots.actions && open" :id="menuId" ref="menu" class="er-fab-actions" role="group" :aria-label="label" tabindex="-1">
<slot name="actions" />
</div>
</Transition>
<button
type="button"
class="er-fab-button"
:data-extended="extended ? '' : undefined"
:aria-label="extended ? undefined : label"
:aria-expanded="$slots.actions ? open : undefined"
:aria-controls="$slots.actions && open ? menuId : undefined"
@click="press"
>
<!-- The icon. A plus when left empty. -->
<span class="er-fab-icon">
<slot>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
</slot>
</span>
<span v-if="extended">{{ label }}</span>
</button>
</div>
</template>
<style>
@layer components {
.er-fab {
display: inline-flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
}
/* Clear of the home indicator and the rounded corners; raise --er-fab-offset to sit above a bottom nav. */
.er-fab[data-fixed] {
position: fixed;
right: max(1rem, env(safe-area-inset-right));
bottom: calc(max(1rem, env(safe-area-inset-bottom)) + var(--er-fab-offset, 0px));
z-index: 20;
}
.er-fab[data-fixed][data-position='bottom-left'] {
right: auto;
left: max(1rem, env(safe-area-inset-left));
align-items: flex-start;
}
.er-fab[data-fixed][data-position='bottom-center'] {
right: auto;
left: 50%;
align-items: center;
transform: translateX(-50%);
}
.er-fab[data-position='bottom-left'] {
align-items: flex-start;
}
.er-fab[data-position='bottom-center'] {
align-items: center;
}
.er-fab-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-width: 3.5rem;
height: 3.5rem;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--er-primary);
color: var(--er-primary-foreground);
box-shadow: var(--er-shadow-lg);
font: inherit;
font-size: 0.9375rem;
font-weight: var(--er-weight-medium);
cursor: pointer;
transition:
background-color var(--er-duration),
transform var(--er-duration);
}
.er-fab-button[data-extended] {
padding: 0 1.25rem 0 1rem;
}
.er-fab-button:hover {
background: color-mix(in oklab, var(--er-primary) 88%, var(--er-background));
}
.er-fab-button:active {
transform: scale(0.96);
}
.er-fab-button:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
.er-fab-icon {
display: grid;
place-items: center;
transition: transform 200ms var(--er-ease);
}
.er-fab-icon svg {
width: 1.5rem;
height: 1.5rem;
}
/* Open: the plus turns into a cross, which is what pressing it again does. */
.er-fab[data-state='open'] .er-fab-icon {
transform: rotate(45deg);
}
.er-fab-actions {
display: flex;
flex-direction: column;
align-items: inherit;
gap: 0.5rem;
outline: none;
}
.er-fab-action {
display: inline-flex;
align-items: center;
gap: 0.5rem;
height: 2.5rem;
padding: 0 0.875rem;
border: 1px solid var(--er-border);
border-radius: 999px;
background: var(--er-popover);
color: var(--er-popover-foreground);
box-shadow: var(--er-shadow-md);
font: inherit;
font-size: 0.875rem;
font-weight: var(--er-weight-medium);
white-space: nowrap;
cursor: pointer;
transition: background-color var(--er-duration);
}
.er-fab-action:hover {
background: var(--er-accent);
}
.er-fab-action:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
.er-fab-action svg {
width: 1.125rem;
height: 1.125rem;
}
.er-fab-actions-enter-active,
.er-fab-actions-leave-active {
transition:
opacity var(--er-duration),
transform 200ms var(--er-ease);
}
.er-fab-actions-enter-from,
.er-fab-actions-leave-to {
opacity: 0;
transform: translateY(0.5rem);
}
@media (prefers-reduced-motion: reduce) {
.er-fab-icon,
.er-fab-button,
.er-fab-actions-enter-active,
.er-fab-actions-leave-active {
transition: none;
}
}
}
</style>FabAction.vue 22 lines
<script setup lang="ts">
import { useFab } from './context'
const emit = defineEmits<{
/** The action was chosen. The stack closes by itself. */
select: []
}>()
const fab = useFab()
function choose() {
fab.close()
emit('select')
}
</script>
<template>
<button type="button" class="er-fab-action" @click="choose">
<slot />
</button>
</template>context.ts 8 lines
import { createContext } from '../../lib/context'
export interface FabContext {
close: () => void
}
export const [provideFab, useFab] = createContext<FabContext>('Fab')index.ts 4 lines
/** FAB — a round button floating over the corner of the screen for the main thing to do there; it can open into a short stack of related actions. */
export { default as Fab } from './Fab.vue'
export { default as FabAction } from './FabAction.vue'