Quick Actions
A spark-ui reconstruction of ECP's Quick action and Quick actions blocks, the icon-and-label shortcuts customers use for common portal tasks.
Quick action (quick_action) and Quick actions (quick_actions) are built-in ECP blocks. Their real implementation lives in the epilot360-ecp-settings repo, which this workspace has no code access to (see Block Catalog). This page is not that source. It's a spark-ui reference reconstruction: how you'd assemble an equivalent shape from @epilot/spark-ui if you were building a custom block that needed this pattern.
Quick actions
Usage
A quick action is a themeable card holding a soft icon chip, a title, and a subtitle. The whole tile is the action target. The container block stacks several of these behind a section title, in one of three layouts.
Quick action
The catalog lists quick_action's content settings as Title, Subtitle, Icon, and Action. The tile is a card whose style and colour come from the block's theme (see Theming the card). Inside it, Icon renders a constant soft chip, and the title and subtitle inherit the card's colour. The tile's own handler stands in for the action (in a real block, its onClick or href would resolve to whatever the Action field points at: a link, a journey, a portal functionality):
import { Icon } from '@epilot/spark-ui/icon'
import { type MaterialSymbolIconName } from '@epilot/spark-ui/icons/material-symbol'
import { cn } from '@/lib/cn'
interface QuickAction {
icon: MaterialSymbolIconName
title: string
subtitle: string
}
export function QuickActionTile({
action,
// "card style" classes for the wrapper, e.g. 'bg-base-accent-soft text-accent'
cardClassName = 'bg-base-accent-soft text-accent',
// the soft chip's colour, matching the card's family
iconClassName = 'bg-base-accent-soft text-accent',
}: {
action: QuickAction
cardClassName?: string
iconClassName?: string
}) {
return (
<button
className={cn(
'group flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-left transition-opacity hover:opacity-90',
cardClassName,
)}
type="button"
>
<Icon className={iconClassName} name={action.icon} variant="soft" />
<div className="min-w-0 flex-1">
<div className="text-base font-medium">{action.title}</div>
<div className="text-sm opacity-70">{action.subtitle}</div>
</div>
</button>
)
}The two theming knobs are cardClassName (the card's style and colour) and the matching iconClassName, both covered under Theming the card. Note the title and subtitle set no colour of their own: they inherit the card's text-* so they stay legible on every style, turning white on a solid card.
Quick actions (container)
The container adds a section title above a set of QuickActionTiles, all sharing one card style, and controls how they're arranged. The default arrangement is a responsive grid:
import { SectionTitle } from '@epilot/spark-ui/typography'
export function QuickActionsGrid() {
return (
<div className="flex flex-col gap-3">
<SectionTitle>Quick actions</SectionTitle>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{QUICK_ACTIONS.map((action) => (
<QuickActionTile action={action} key={action.title} />
))}
</div>
</div>
)
}The catalog names three layouts for quick_actions: Grid, Scroll box, and Stacked. Grid and Stacked only change the wrapper's classes; Scroll box swaps the wrapper for a horizontal ScrollArea and gives each tile a fixed width so the row can overflow:
import { ScrollArea, ScrollBar } from '@epilot/spark-ui/scroll-area'
export function QuickActionsScrollBox() {
return (
<div className="flex flex-col gap-3">
<SectionTitle>Quick actions</SectionTitle>
<ScrollArea className="w-full">
<div className="flex w-max gap-2 pb-3">
{QUICK_ACTIONS.map((action) => (
<QuickActionTile
action={action}
className="w-56"
key={action.title}
/>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)
}export function QuickActionsStacked() {
return (
<div className="flex flex-col gap-3">
<SectionTitle>Quick actions</SectionTitle>
<div className="flex flex-col gap-2">
{QUICK_ACTIONS.map((action) => (
<QuickActionTile action={action} key={action.title} />
))}
</div>
</div>
)
}Theming the card
The design concept themes the card (the tile wrapper), not the icon. The card carries a style and a colour; the icon inside stays a constant soft chip. Pick one style + colour for the whole block.
Style
The five card styles are Soft, Surface, Outlined, Solid, and Ghost. spark's Card component exposes most of these directly (variant accepts soft, surface, outline, ghost, and color accepts neutral or accent). The reconstruction styles a plain button with semantic tokens instead, so it can also show solid and the full colour palette, both of which are on the roadmap for Card's own props. With family standing in for the colour's token family (see below), each style is:
| Style | Wrapper classes |
|---|---|
| Soft | bg-base-{family}-soft text-{family} |
| Surface | bg-base-{family}-surface border border-{family} text-{family} |
| Outlined | border border-{family} text-{family} |
| Solid | bg-base-{family}-solid text-{family}-contrast |
| Ghost | text-{family} |
The bare border-{family} utility already resolves to a subtle, family-tinted edge, so surface and outline cards need no extra opacity tuning.
Colour
The six preview colours map to spark's six semantic token families:
| Preview colour | Token family |
|---|---|
| Brand | accent |
| Gray | gray |
| Red | error |
| Green | success |
| Blue | info |
| Orange | warning |
So a red Outlined card is border border-error text-error, and a green Solid card is bg-base-success-solid text-success-contrast. The icon chip always uses the family's soft treatment (bg-base-{family}-soft text-{family}) so it reads as a soft chip of the same colour on every style.
Brand and Gray are not fixed hues: they track the theme's accentColor and neutralColor knobs, so a Brand card repaints when the app's brand colour changes. Red, Green, Blue, and Orange are fixed semantic hues by design (a warning should stay recognisably orange no matter the brand), so they don't have a hue picker. This is why the preview's Accent hue and Neutral hue controls only appear for Brand and Gray respectively.
High contrast
The concept also shows a high-contrast row (deeper solids, stronger outlines). High contrast is an ambient theme knob, the data-high-contrast attribute on any ancestor, and spark components that read the --spark-style-* scale (Button, and Card through its variant) pick it up automatically. The semantic base-* token classes shown here point at fixed slots, so a token-styled card does not shift under high contrast on its own. Matching the concept's high-contrast card row is part of the roadmap for a first-class themeable Card.
Size
quick_action's own inline setting is Size (Small/Medium/Large). Rather than hand-tuning an icon size and padding per tile, the reconstruction sizes the block through a theme knob: the scaling prop on a scoped Theme. scaling sets [data-scaling], which feeds --spark-scaling into both --spacing and --text-*, so the tile's padding, type, and icon all scale together. Because Theme is nestable, wrapping just the block scopes the size to that block without touching the rest of the page:
import { Theme } from '@epilot/spark-ui/theme'
// Small -> '90%', Medium -> '100%', Large -> '110%'
;<Theme className="contents" scaling="110%">
<QuickActionsGrid />
</Theme>className="contents" keeps the wrapper out of the layout while still setting the scaling variable for everything inside. This is the spark-native way to size a block: one knob scales the whole tile, so nothing drifts from the theme.
The rest of the theme knobs
Theme takes more than scaling. The preview's Radius, Spacing, Accent hue, and Neutral hue controls are the same nested <Theme>, just with more props set at once:
<Theme
accentColor="teal" // omit to inherit the app's own brand colour
className="contents"
neutralColor="sage" // omit to inherit the app's own neutral
radius="large"
scaling="100%"
spacing="roomy"
>
<QuickActionsGrid />
</Theme>- Radius (
none/small/medium/large/full) sets[data-radius], which scales--spark-rounded-smthrough--spark-rounded-3xl. The tile'srounded-2xlclass reads--spark-rounded-2xlunder the hood, so it reshapes with the rest of the app, no per-tile radius class needed. - Spacing (
dense/default/roomy) sets[data-spacing], which drives--spark-layout-gap— the space between components in spark-ui's layout and collection primitives (PageContainer,List,RadioGroup, …). Since the "between-only" model it no longer touches component internals (padding, control sizes) or type size;scalingis the knob that zooms those. This block composes its own grid gaps, so the spacing knob has little visible effect on the tiles here — it shows up where the primitives above stack content. - Accent hue picks the curated colour or hex behind
accentColor, and only visibly changes anything when Colour is set to Brand, since Brand is the one family that reads the--spark-*accent scaleaccentColorregenerates. - Neutral hue picks the
neutralColorpreset (slate/mauve/gray/sage/olive/sand, orautoto pair it with the current accent), and only changes anything when Colour is Gray, for the same reason.
Leaving accentColor or neutralColor unset (the preview's "App default" option) is deliberate: a nested Theme falls back to whatever the nearest ancestor Theme has configured, so a block author only needs to override the knobs the block actually cares about.
See Card for the component's native variant/color props, Icon for the chip's variant, Theme Playground for the full set of theme knobs, and Scroll Area for the scroll-box wrapper.