Speed Dial
speed-dialuiExpanding floating action button (Fab + Popover) that reveals secondary icon actions. Click or hover triggers, four expansion directions, 40ms staggered entrance, configurable FAB icon template, close-on-action, disabled actions.
Installation
$pnpm dlx uipkge-ng@latest add speed-dial$npx uipkge-ng@latest add speed-dial$yarn dlx uipkge-ng@latest add speed-dial$bunx uipkge-ng@latest add speed-dialManifest:
https://uipkge.dev/r/angular/speed-dial.jsonInstalls to:src/app/components/ui/speed-dial/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
actions | SpeedDialAction[] | [] | optional |
iconMain FAB icon template (defaults to a plus). | TemplateRef<unknown> | — | optional |
labelAccessible label for the main FAB. | string | — | optional |
direction | 'up''down''left''right' | up | optional |
trigger | 'click''hover' | click | optional |
closeOnActionClose 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 | false | optional |
class | string | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
SpeedDialActioninterface SpeedDialAction {
/** Icon template (React passes a LucideIcon component): `<ng-template #mail><svg ...></svg></ng-template>`. */
icon: TemplateRef<unknown>
label: string
handler?: () => void
disabled?: boolean
className?: string
}Files installed (2)
src/app/components/ui/speed-dial/speed-dial.component.ts7.8 kB
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, TemplateRef, ViewChild, booleanAttribute, signal, ChangeDetectionStrategy, } from '@angular/core' import { cn } from '@/lib/utils' import { UiFabComponent } from '@/ui/fab/fab.component' import { UiPopoverComponent, UiPopoverContentComponent } from '@/ui/popover/popover.component' import { UiRenderTemplateDirective } from '@/ui/popper/popper' export interface SpeedDialAction { /** Icon template (React passes a LucideIcon component): `<ng-template #mail><svg ...></svg></ng-template>`. */ icon: TemplateRef<unknown> label: string handler?: () => void disabled?: boolean className?: string } export type SpeedDialDirection = 'up' | 'down' | 'left' | 'right' export type SpeedDialTrigger = 'click' | 'hover' export type SpeedDialVariant = 'default' | 'secondary' | 'destructive' | 'outline' export type SpeedDialPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'bottom-center' | 'inline' const sideMap: Record<SpeedDialDirection, 'top' | 'bottom' | 'left' | 'right'> = { up: 'top', down: 'bottom', left: 'left', right: 'right', } const ACTION_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" /** * Angular port of UIPKGE SpeedDial (React: Popover + Fab). The trigger is a wrapper div * around the Fab (Radix `PopoverTrigger asChild`); actions render in a transparent * popover panel on the side given by `direction`, 12px from the FAB, each fading in with * a 40ms stagger. `trigger="hover"` opens on mouse enter and closes 150ms after the * pointer leaves both the FAB and the actions. Same props and defaults as React. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-speed-dial, [ui-speed-dial]', standalone: true, imports: [UiPopoverComponent, UiPopoverContentComponent, UiFabComponent, UiRenderTemplateDirective], // React renders no wrapper around the Popover root. host: { class: 'contents' }, template: ` <ui-popover #popover [open]="open()" (openChange)="handleOpenChange($event)"> <!-- Radix PopoverTrigger asChild: trigger props merged onto the wrapper div. --> <div #triggerEl data-uipkge="" data-slot="speed-dial" aria-haspopup="dialog" [attr.aria-expanded]="open()" [attr.aria-controls]="popover.contentId" [attr.data-state]="open() ? 'open' : 'closed'" [class]="rootClass" (mouseenter)="onTriggerEnter()" (mouseleave)="onTriggerLeave()" (click)="popover.toggle()" > <button ui-fab [variant]="variant" [position]="position" [absolute]="absolute" [disabled]="disabled" [ariaLabel]="label || 'Quick actions'" [attr.aria-expanded]="open()" aria-haspopup="menu" [class]="fabClass" (click)="onFabClick($event)" > @if (icon) { <ng-container [uiRenderTemplate]="icon" /> } @else { <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus" aria-hidden="true" > <path d="M5 12h14" /> <path d="M12 5v14" /> </svg> } </button> </div> <ui-popover-content [side]="side" align="center" [sideOffset]="12" [class]="contentClass"> <div [class]="listClass" (mouseenter)="onContentEnter()" (mouseleave)="onContentLeave()"> @for (action of actions; track $index; let i = $index) { <button type="button" data-slot="speed-dial-action" [disabled]="action.disabled" [attr.aria-label]="action.label" [style.animation-delay.ms]="i * 40" [class]="actionClass(action)" (click)="runAction(action)" > <ng-container [uiRenderTemplate]="action.icon" /> <span class="sr-only">{{ action.label }}</span> </button> } </div> </ui-popover-content> </ui-popover> `, }) export class UiSpeedDialComponent implements AfterViewInit, OnDestroy { @ViewChild('popover', { static: true }) popover!: UiPopoverComponent @ViewChild('triggerEl', { static: true }) triggerEl!: ElementRef<HTMLElement> @Input() actions: SpeedDialAction[] = [] /** Main FAB icon template (defaults to a plus). */ @Input() icon?: TemplateRef<unknown> /** Accessible label for the main FAB. */ @Input() label?: string @Input() direction: SpeedDialDirection = 'up' @Input() trigger: SpeedDialTrigger = 'click' /** Close the dial after an action is triggered. */ @Input({ transform: booleanAttribute }) closeOnAction = true @Input() variant: SpeedDialVariant = 'default' @Input() position: SpeedDialPosition = 'bottom-right' @Input({ transform: booleanAttribute }) absolute = false @Input({ transform: booleanAttribute }) disabled = false @Input('class') className?: string readonly open = signal(false) private hoverTimer: ReturnType<typeof setTimeout> | null = null get side() { return sideMap[this.direction] } get rootClass(): string { return cn(this.className) } get fabClass(): string { return cn( 'transition-[color,background-color,box-shadow,transform,scale,translate,rotate] duration-200', this.open() && 'rotate-45', ) } get contentClass(): string { return cn('w-auto border-0 bg-transparent p-0 shadow-none', this.trigger === 'hover' && 'pointer-events-auto') } get listClass(): string { return this.direction === 'up' || this.direction === 'down' ? 'flex flex-col items-center gap-3' : 'flex flex-row items-center gap-3' } actionClass(action: SpeedDialAction): string { return cn(ACTION_CLASS, action.className) } private clearHoverTimer(): void { if (this.hoverTimer) { clearTimeout(this.hoverTimer) this.hoverTimer = null } } private closeLater(): void { this.clearHoverTimer() this.hoverTimer = setTimeout(() => this.open.set(false), 150) } onTriggerEnter(): void { if (this.disabled || this.trigger !== 'hover') return this.clearHoverTimer() this.open.set(true) } onTriggerLeave(): void { if (this.trigger !== 'hover') return this.closeLater() } onContentEnter(): void { if (this.trigger !== 'hover') return this.clearHoverTimer() } onContentLeave(): void { if (this.trigger !== 'hover') return this.closeLater() } /** In hover mode a click on the FAB must not toggle the popover (React stops propagation). */ onFabClick(event: MouseEvent): void { if (this.trigger === 'hover') event.stopPropagation() } /** The trigger is a wrapper div, so a disabled Fab alone does not block the popover. */ handleOpenChange(next: boolean): void { this.open.set(this.disabled ? false : next) } runAction(action: SpeedDialAction): void { if (action.disabled) return action.handler?.() if (this.closeOnAction) this.open.set(false) } ngAfterViewInit(): void { // The popover anchors to (and returns focus to) the wrapper div. this.popover.trigger = this.triggerEl.nativeElement } ngOnDestroy(): void { this.clearHoverTimer() } }src/app/components/ui/speed-dial/index.ts0.2 kB
export { UiSpeedDialComponent, type SpeedDialAction, type SpeedDialDirection, type SpeedDialTrigger, type SpeedDialVariant, type SpeedDialPosition, } from './speed-dial.component'
Raw manifest:https://uipkge.dev/r/angular/speed-dial.json