Speed Dial
speed-dial ui Expanding floating action button that reveals a list of secondary action buttons. Supports click or hover triggers, four expansion directions (up/down/left/right), staggered entrance animation, a configurable main FAB icon, and close-on-action. Built on the fab and popover primitives.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/speed-dial.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/speed-dial.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/speed-dial.json $ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/speed-dial.json Named registry:
npx shadcn-vue@latest add @uipkge/speed-dial Installs to: app/components/ui/speed-dial/ Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
actions | SpeedDialAction[] | — | required |
icon Main FAB icon. | Component | — | optional |
label Accessible label for the main FAB. | string | — | optional |
direction | Direction | 'up' | optional |
trigger | Trigger | 'click' | optional |
closeOnAction Close the dial after an action is triggered. | boolean | true | optional |
variant | 'default''secondary''destructive''outline' | 'default' | optional |
position | 'bottom-right''bottom-left''top-right''top-left''bottom-center''inline' | 'bottom-right' | optional |
absolute | boolean | false | optional |
disabled | boolean | — | optional |
class | HTMLAttributes['class'] | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
SpeedDialAction interface SpeedDialAction {
icon: Component
label: string
handler?: () => void
disabled?: boolean
class?: HTMLAttributes['class']
} npm dependencies
Files installed (2)
-
app/components/ui/speed-dial/SpeedDial.vue 4.4 kB
<script setup lang="ts"> import type { Component, HTMLAttributes } from 'vue' import { computed, ref } from 'vue' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Fab } from '@/components/ui/fab' import { cn } from '@/lib/utils' import { Plus } from 'lucide-vue-next' export interface SpeedDialAction { icon: Component label: string handler?: () => void disabled?: boolean class?: HTMLAttributes['class'] } type Direction = 'up' | 'down' | 'left' | 'right' type Trigger = 'click' | 'hover' interface Props { actions: SpeedDialAction[] /** Main FAB icon. */ icon?: Component /** Accessible label for the main FAB. */ label?: string direction?: Direction trigger?: Trigger /** Close the dial after an action is triggered. */ closeOnAction?: boolean variant?: 'default' | 'secondary' | 'destructive' | 'outline' position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'bottom-center' | 'inline' absolute?: boolean disabled?: boolean class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { direction: 'up', trigger: 'click', closeOnAction: true, variant: 'default', position: 'bottom-right', absolute: false, }) const open = ref(false) let hoverTimer: ReturnType<typeof setTimeout> | null = null function onTriggerEnter() { if (props.trigger !== 'hover') return if (hoverTimer) clearTimeout(hoverTimer) open.value = true } function onTriggerLeave() { if (props.trigger !== 'hover') return if (hoverTimer) clearTimeout(hoverTimer) hoverTimer = setTimeout(() => (open.value = false), 150) } function onContentEnter() { if (props.trigger !== 'hover') return if (hoverTimer) clearTimeout(hoverTimer) } function onContentLeave() { if (props.trigger !== 'hover') return if (hoverTimer) clearTimeout(hoverTimer) hoverTimer = setTimeout(() => (open.value = false), 150) } function runAction(action: SpeedDialAction) { if (action.disabled) return action.handler?.() if (props.closeOnAction) open.value = false } const side = computed(() => { switch (props.direction) { case 'up': return 'top' case 'down': return 'bottom' case 'left': return 'left' case 'right': return 'right' } }) const listClass = computed(() => { const base = 'flex items-center gap-3' return props.direction === 'up' || props.direction === 'down' ? `${base} flex-col` : `${base} flex-row` }) </script> <template> <Popover v-model:open="open"> <PopoverTrigger as-child> <div data-uipkge data-slot="speed-dial" :class="cn(props.class)" @mouseenter="onTriggerEnter" @mouseleave="onTriggerLeave" > <Fab :variant="variant" :position="position" :absolute="absolute" :disabled="disabled" :aria-label="label || 'Quick actions'" :class="cn('transition-transform duration-200', open && 'rotate-45')" @click="trigger === 'hover' ? $event.stopPropagation() : undefined" > <component :is="icon" v-if="icon" /> <Plus v-else /> </Fab> </div> </PopoverTrigger> <PopoverContent :side="side" align="center" :side-offset="12" :class="cn('w-auto border-0 bg-transparent p-0 shadow-none', trigger === 'hover' && 'pointer-events-auto')" @mouseenter="onContentEnter" @mouseleave="onContentLeave" > <div :class="listClass"> <button v-for="(action, i) in actions" :key="i" type="button" data-slot="speed-dial-action" :disabled="action.disabled" :aria-label="action.label" :style="{ animationDelay: `${i * 40}ms` }" class="group/speed-dial-item bg-background text-foreground hover:bg-accent hover:text-accent-foreground motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95 focus-visible:ring-ring/50 inline-flex size-12 items-center justify-center rounded-full border shadow-md transition-colors outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5" :class="action.class" @click="runAction(action)" > <component :is="action.icon" /> <span class="sr-only">{{ action.label }}</span> </button> </div> </PopoverContent> </Popover> </template> -
app/components/ui/speed-dial/index.ts 0.1 kB
export { default as SpeedDial } from './SpeedDial.vue' export type { SpeedDialAction } from './SpeedDial.vue'
Raw manifest: https://uipkge.dev/r/vue/speed-dial.json