defuss-shadcn / Actions / fab
FABMOL
A floating action button - the one main action of a screen, always in reach - and its speed dial: a few related actions that fan out from it. The dial is a native popover, so opening, Escape, clicking outside and aria-expanded are the browser's; CSS anchor positioning stacks the actions at the trigger. No JavaScript.
On this page (14)
§Speed dial
Click the + (or focus it and press Enter): the actions fan out above it, the + turns into a ×. Click the × again, pick an action (each has popovertargetaction='hide'), press Escape or click outside to close it. The FAB is fixed to the corner of the viewport - here, of the example frame.
§With labels
A .fab-label in each action is the chip beside it - and the button's accessible name.
§Main action
A .fab-main takes the trigger's place while the dial is open (daisyUI's main action): the second click does the main thing - here, a new message.
§Close icon
Two icons in the trigger - .fab-icon-closed and .fab-icon-open - swap instead of turning. Any glyph works, emojis too.
§Flower
data-layout='flower' spreads the actions on a quarter circle around the trigger, opening toward the middle of the screen; 2 - 6 actions spread evenly.
§Flower with a main action
The main action stays in the middle, the others around it.
§Corners and directions
data-position picks the corner (bottom-end, bottom-start, top-end, top-start, bottom-center), data-direction where the actions go (up, down, left, right); a flower opens toward the middle from any corner. Labels switch sides with the corner.
§Text actions
A .fab-text inside an action makes a pill with the words in it - clearer than icons for a few verbs.
§Colors and sizes
The trigger is a .btn: data-tone and data-variant work. data-size sm / md / lg scales the trigger and the actions. data-attach='container' pins a FAB inside a positioned box instead of the viewport.
§Backdrop
data-backdrop dims and softly blurs the page while the dial is open, so the actions read as the next step.
§Extended, shrinks on scroll
A .fab-text in the trigger makes an extended FAB (icon + label). data-shrink='scroll' folds the label away over the first 120px of scrolling - a scroll-driven animation, no listener. It follows the page by default; here it follows the list (a named scroll-timeline + --fab-timeline). Scroll the list.
§Back to top
data-scroll-top: the trigger is a link to the start - href='#top' scrolls a page to its top natively (no element needed); in a scrolling box it links to an element at the box's start. A scroll-driven animation keeps it hidden (and unfocusable) near the top and fades it in after 160px; the jump glides unless reduced motion is asked for. No script.
§Right to left
Positions are logical: bottom-end is the bottom-left corner in dir='rtl', and the labels switch sides with it.
§CSS view file
/* -- FAB (floating action button) + speed dial ---------------------- */@layer components { /* A .fab holds a .fab-trigger (a .btn with popovertarget) and, for a speed dial, a .fab-menu[popover] of .fab-action buttons. The popover is the whole behavior: click / Enter toggles it, Escape and an outside click close it, the trigger reports aria-expanded - no JavaScript. The menu is anchored to the trigger (anchor-scope keeps many FABs apart) and stacks its actions away from it: data-direction up (default) / down / left / right, or data-layout="flower" for a quarter circle. */ .fab { --_trig: 3.5rem; --_act: 2.75rem; --_gap: 0.75rem; --_edge: 1.5rem; anchor-scope: --fab; position: fixed; z-index: 50; inset-block-end: var(--_edge); inset-inline-end: var(--_edge); display: flex; /* -- Position: the corner (or edge center) it floats in ------------- */ &[data-position="bottom-start"] { inset-inline-end: auto; inset-inline-start: var(--_edge); } &[data-position="top-end"] { inset-block-end: auto; inset-block-start: var(--_edge); } &[data-position="top-start"] { inset: var(--_edge) auto auto var(--_edge); } &[data-position="bottom-center"] { inset-inline: 0; justify-content: center; pointer-events: none; & > * { pointer-events: auto; } } /* inside a positioned container (a card, a panel) instead of the page */ &[data-attach="container"] { position: absolute; } /* -- Sizes: trigger + actions scale together (md == default) --------- */ &[data-size="sm"] { --_trig: 2.75rem; --_act: 2.25rem; --_gap: 0.5rem; } &[data-size="md"] { --_trig: 3.5rem; --_act: 2.75rem; --_gap: 0.75rem; } &[data-size="lg"] { --_trig: 4.25rem; --_act: 3.25rem; --_gap: 0.875rem; } } /* -- Trigger ------------------------------------------------------------ */ .fab-trigger { anchor-name: --fab; width: var(--_trig); height: var(--_trig); padding: 0; border-radius: 9999px; box-shadow: var(--shadow-lg); & svg { width: 1.375rem; height: 1.375rem; } & .fab-icon { display: inline-grid; place-items: center; font-size: 1.375rem; line-height: 1; transition: rotate 200ms ease; } /* extended: icon + label, a pill */ /* icon-only width == the round trigger, so a folded label leaves a circle */ &:has(.fab-text) { width: auto; min-width: var(--_trig); padding-inline: calc((var(--_trig) - 1.375rem) / 2); gap: 0; } & .fab-text { margin-inline-start: 0.625rem; padding-inline-end: 0.25rem; } } .fab-text { overflow: hidden; white-space: nowrap; font-size: 0.9375rem; } /* open: the trigger's icon turns (+ becomes ×) - or swaps with .fab-icon-open / .fab-icon-closed */ .fab:has(.fab-menu:popover-open) .fab-trigger .fab-icon:not(.fab-icon-open, .fab-icon-closed) { rotate: 45deg; } .fab:not(:has(.fab-menu:popover-open)) .fab-icon-open, .fab:has(.fab-menu:popover-open) .fab-icon-closed { display: none; } /* -- The menu (a popover in the top layer, anchored to the trigger) ----- */ .fab-menu[popover] { position: fixed; position-anchor: --fab; inset: auto; width: auto; height: auto; margin: 0; padding: 0; border: 0; background: none; color: inherit; overflow: visible; display: none; gap: var(--_gap); align-items: center; /* up (default): stacked above the trigger, first action nearest */ bottom: anchor(bottom); left: anchor(center); translate: -50% 0; flex-direction: column-reverse; padding-block-end: calc(var(--_trig) + var(--_gap)); &:popover-open { display: flex; } /* the menu box overlaps the trigger (its padding leaves room for it) - only the actions take the pointer, so the trigger stays clickable to close */ pointer-events: none; & > * { pointer-events: auto; } transition: display 180ms allow-discrete, overlay 180ms allow-discrete; } .fab[data-direction="down"] .fab-menu[popover] { bottom: auto; top: anchor(top); flex-direction: column; padding-block: calc(var(--_trig) + var(--_gap)) 0; } .fab[data-direction="left"] .fab-menu[popover] { bottom: auto; left: auto; right: anchor(right); top: anchor(center); translate: 0 -50%; flex-direction: row-reverse; padding: 0 calc(var(--_trig) + var(--_gap)) 0 0; } .fab[data-direction="right"] .fab-menu[popover] { bottom: auto; left: anchor(left); top: anchor(center); translate: 0 -50%; flex-direction: row; padding: 0 0 0 calc(var(--_trig) + var(--_gap)); } /* an optional dim layer behind the open dial */ .fab[data-backdrop] .fab-menu::backdrop { background-color: color-mix(in oklch, var(--foreground) 22%, transparent); backdrop-filter: blur(2px); } /* text dials: the pills line up with the trigger's outer edge */ .fab-menu[popover]:has(> .fab-action > .fab-text) { left: auto; right: anchor(right); translate: none; align-items: flex-end; } .fab:is([data-position="bottom-start"], [data-position="top-start"]) .fab-menu[popover]:has(> .fab-action > .fab-text) { right: auto; left: anchor(left); align-items: flex-start; } /* -- Actions ------------------------------------------------------------ */ .fab-action { position: relative; display: inline-grid; place-items: center; width: var(--_act); height: var(--_act); padding: 0; border: 1px solid var(--border); border-radius: 9999px; background-color: var(--popover); color: var(--popover-foreground); box-shadow: var(--shadow-md); font: inherit; font-size: 1.125rem; line-height: 1; text-decoration: none; cursor: pointer; transition: opacity 160ms ease, scale 160ms ease, translate 160ms ease, background-color 150ms ease; transition-delay: calc(var(--_i, 0) * 35ms); &:hover { background-color: var(--accent); color: var(--accent-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } & svg { width: 1.125rem; height: 1.125rem; } /* .btn tones / variants work on an action too */ &.btn { border-radius: 9999px; width: var(--_act); height: var(--_act); padding: 0; } } /* text actions: a pill with the words inside */ .fab-action:has(> .fab-text) { display: inline-flex; align-items: center; width: auto; padding-inline: 0.875rem 1.125rem; gap: 0.5rem; white-space: nowrap; font-size: 0.875rem; font-weight: 500; } .fab-action .fab-text { font-size: inherit; } /* the stagger: actions pop out from the trigger one after another */ .fab-menu > :nth-child(2) { --_i: 1; } .fab-menu > :nth-child(3) { --_i: 2; } .fab-menu > :nth-child(4) { --_i: 3; } .fab-menu > :nth-child(5) { --_i: 4; } .fab-menu > :nth-child(6) { --_i: 5; } .fab-menu > :nth-child(7) { --_i: 6; } .fab-menu:not(:popover-open) > * { opacity: 0; scale: 0.6; } @starting-style { .fab-menu:popover-open > * { opacity: 0; scale: 0.6; } .fab[data-layout="flower"] .fab-menu:popover-open > * { translate: -50% -50%; } } /* label chips beside the actions (toward the screen's middle) */ .fab-label { position: absolute; inset-inline-end: calc(100% + 0.625rem); top: 50%; translate: 0 -50%; padding: 0.25rem 0.625rem; border: 1px solid var(--border); border-radius: var(--radius-md); background-color: var(--popover); color: var(--popover-foreground); box-shadow: var(--shadow-sm); font-size: 0.8125rem; font-weight: 500; white-space: nowrap; pointer-events: none; } .fab:is([data-position="bottom-start"], [data-position="top-start"]) .fab-label { inset-inline-end: auto; inset-inline-start: calc(100% + 0.625rem); } /* sideways dials: the label sits above its action */ .fab:is([data-direction="left"], [data-direction="right"]) .fab-label { inset-inline: auto; left: 50%; top: auto; bottom: calc(100% + 0.5rem); translate: -50% 0; } /* -- Main action: an action that takes the trigger's place when open ---- */ .fab-menu > .fab-main { --_i: 0; width: var(--_trig); height: var(--_trig); border: 0; background-color: var(--primary); color: var(--primary-foreground); box-shadow: var(--shadow-lg); font-size: 1.375rem; &:hover { background-color: color-mix(in oklch, var(--primary) 88%, var(--primary-foreground)); color: var(--primary-foreground); } & svg { width: 1.375rem; height: 1.375rem; } } .fab-menu:has(> .fab-main) { padding: 0 !important; } .fab-menu:has(> .fab-main) > :not(.fab-main) { --_i: 1; } /* -- Flower: the actions on a quarter circle around the trigger -------- The arc opens toward the screen's middle (up-left from bottom-end); 2 - 6 actions spread evenly. Labels are visually hidden here (they still name the buttons). */ .fab[data-layout="flower"] { --_r: calc(var(--_trig) + var(--_act) * 1.6); --_arc: 90deg; --_a0: 0deg; --_sx: -1; --_sy: -1; &:is([data-position="bottom-start"], [data-position="top-start"]) { --_sx: 1; } &:is([data-position="top-end"], [data-position="top-start"]) { --_sy: 1; } /* from the center of an edge (a dock's raised action): a semicircle */ /* the upper arc only (30° - 150°): the ends stay clear of whatever sits level with the trigger (a dock's neighbouring items) */ &:is([data-position="bottom-center"], [data-position="top-center"]) { --_arc: 120deg; --_a0: 30deg; --_sx: -1; --_r: calc(var(--_trig) + var(--_act) * 0.9); } &[data-position="bottom-center"] { --_sy: -1; } &[data-position="top-center"] { --_sy: 1; } & .fab-menu[popover] { top: anchor(center); left: anchor(center); bottom: auto; right: auto; width: 0; height: 0; padding: 0; translate: none; display: none; &:popover-open { display: block; } } & .fab-menu > * { --_step: var(--_arc); --_a: calc(var(--_a0) + var(--_k, 0) * var(--_step)); position: absolute; left: 0; top: 0; translate: calc(-50% + var(--_sx) * cos(var(--_a)) * var(--_r)) calc(-50% + var(--_sy) * sin(var(--_a)) * var(--_r)); } & .fab-menu:not(:popover-open) > * { translate: -50% -50%; } & .fab-label { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); border: 0; } /* k = 0 … n-1 along the arc (the main action, if any, stays centered) */ & .fab-menu > :not(.fab-main):nth-child(1 of :not(.fab-main)) { --_k: 0; } & .fab-menu > :not(.fab-main):nth-child(2 of :not(.fab-main)) { --_k: 1; } & .fab-menu > :not(.fab-main):nth-child(3 of :not(.fab-main)) { --_k: 2; } & .fab-menu > :not(.fab-main):nth-child(4 of :not(.fab-main)) { --_k: 3; } & .fab-menu > :not(.fab-main):nth-child(5 of :not(.fab-main)) { --_k: 4; } & .fab-menu > :not(.fab-main):nth-child(6 of :not(.fab-main)) { --_k: 5; } & .fab-menu:has(> :not(.fab-main):nth-child(3 of :not(.fab-main))) > * { --_step: calc(var(--_arc) / 2); } & .fab-menu:has(> :not(.fab-main):nth-child(4 of :not(.fab-main))) > * { --_step: calc(var(--_arc) / 3); } & .fab-menu:has(> :not(.fab-main):nth-child(5 of :not(.fab-main))) > * { --_step: calc(var(--_arc) / 4); --_r: calc(var(--_trig) + var(--_act) * 2.1); } & .fab-menu:has(> :not(.fab-main):nth-child(6 of :not(.fab-main))) > * { --_step: calc(var(--_arc) / 5); } & .fab-menu > .fab-main { translate: -50% -50%; } } /* -- Extended FAB that shrinks to its icon as the page scrolls ---------- data-shrink="scroll": a scroll-driven animation (no listener) folds the label away over the first 120px of scrolling. It follows the page; for a scrolling container, name its timeline (scroll-timeline: --list; timeline-scope on a shared ancestor) and set --fab-timeline: --list. */ @keyframes fab-shrink { to { max-width: 0; opacity: 0; margin-inline-start: 0; padding-inline-end: 0; } } .fab[data-shrink="scroll"] .fab-trigger { & .fab-text { max-width: 12rem; animation: fab-shrink linear both; animation-timeline: var(--fab-timeline, scroll(root block)); animation-range: 0 120px; } } /* -- Back to top -------------------------------------------------------- data-scroll-top: the trigger is a link to the start (href="#top" - the browser scrolls the document to its top natively, even without an element of that id; for a scrolling container, link to an element at its start). A scroll-driven animation keeps it hidden at the top and fades / grows it in between --fab-scroll-top-from and -to of scroll (default 160px - 320px); visibility follows, so it is not focusable while hidden. The timeline is a var() - minifiers leave it alone. */ @keyframes fab-appear { from { opacity: 0; scale: 0.6; visibility: hidden; } } .fab[data-scroll-top] .fab-trigger { animation: fab-appear linear both; animation-timeline: var(--fab-timeline, scroll(root block)); animation-range: var(--fab-scroll-top-from, 160px) var(--fab-scroll-top-to, 320px); } /* the jump glides - for people who have not asked for less motion */ @media (prefers-reduced-motion: no-preference) { html:has(.fab[data-scroll-top]) { scroll-behavior: smooth; } } /* -- Accessibility -------------------------------------------------------- */ @media (prefers-reduced-motion: reduce) { .fab-action, .fab-trigger .fab-icon, .fab-menu[popover] { transition: none; } .fab[data-shrink="scroll"] .fab-trigger .fab-text { animation: none; } .fab[data-scroll-top] .fab-trigger { animation: none; } } @media (forced-colors: active) { .fab-action, .fab-label { border: 1px solid CanvasText; } }}Comments, ideas or improvements? Edit this page's source on GitHub