Teaser & Campaign
A spark-ui reconstruction of ECP's Teaser and Campaign blocks, themeable promo cards with an image, a headline, and a call to action.
Teaser (teaser) and Campaign (campaign) 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.
Markup
Both blocks share one shape: a themed wrapper holding a content box (title, optional subtitle, optional button) and an image, arranged by a layout setting. The content box is its own themed surface nested inside the wrapper, not just a plain text block, so a Teaser can read as one flat card (content box style "None", the box stays transparent and inherits the wrapper's colour) or as two visually separate surfaces layered together:
import { Button } from '@epilot/spark-ui/button'
export function Teaser() {
return (
<div className="relative w-full overflow-hidden rounded-2xl bg-base-accent-soft p-4 text-accent">
<div className="flex h-full flex-col gap-4">
{/* content box, style "None": transparent, inherits the wrapper's colour */}
<div className="flex flex-col items-start gap-2 rounded-2xl text-left">
<div className="text-base font-medium">
Switch to a smart tariff and save up to 15%
</div>
<div className="text-sm opacity-70">
Dynamic pricing adjusts automatically so you use power when it's
cheapest.
</div>
<Button className="mt-1">See smart tariffs</Button>
</div>
{/* image */}
<div className="flex w-full items-center justify-center overflow-hidden rounded-lg bg-base-gray-soft">
<img
alt="Rows of solar panels under a partly cloudy sky"
className="h-auto w-full"
src="/smart-tariff-solar.jpg"
/>
</div>
</div>
</div>
)
}Layout
The catalog's layout setting picks one of three arrangements, and a Direction setting (Default or Revert) flips the order within Stacked and Inline. Stacked places the content box above the image in a column (Revert puts it below). Inline places them side by side in a row (Revert swaps which side each one is on). Floating turns the image into a full-bleed background and overlays the content box on top of it, like a poster; since the image already fills the whole wrapper there's no order left to flip, so Floating has no Direction setting, only the overlay's position moves.
Stacked
<div className="flex h-full flex-col gap-4">
<ContentBox />
<ImageArea />
</div>Inline
<div className="flex h-full flex-row items-center gap-4">
<ContentBox className="flex-1" />
<ImageArea className="flex-1" />
</div>Floating (image absolutely fills the wrapper, content overlays it)
<div className="relative aspect-[16/9] overflow-hidden rounded-2xl">
<ImageArea className="absolute inset-0" fill />
<div className="absolute inset-0 flex items-end justify-start p-4">
<ContentBox />
</div>
</div>Alignment
Alignment adapts to whichever layout is active, because each one arranges its two children along a different axis:
| Layout | Values | Classes applied |
|---|---|---|
| Stacked | Top / Center / Bottom | justify-start / justify-center / justify-end |
| Inline | Start / Center / End | items-start / items-center / items-end |
| Floating | a 3×3 position grid | items-{start,center,end} × justify-{start,center,end} |
Stacked distributes content and image along the vertical (main) axis; Inline distributes them along the cross axis instead, since the row's main axis is already spoken for by the two children sitting side by side. Floating positions the whole overlay (the content box) anywhere in the frame, from top-left to bottom-right.
None of this touches the content box's own Content align, a separate setting for whether the title, subtitle, and button sit at the box's start, center, or end (items-start text-left, items-center text-center, or items-end text-right) independent of where the box itself sits in the wrapper.
Size and aspect ratio
The wrapper's aspect ratio is Auto, 16:9, 3:2, 7:5, 4:3, 5:4, Square, or 3:4. Auto applies no aspect class at all, so the wrapper fits its content's natural height instead of a fixed ratio. Floating is the one layout that needs a height regardless of that setting: its image is absolutely positioned, so if Auto is selected the wrapper falls back to a 16:9 ratio just for Floating, giving the image something to fill.
spark's Thumbnail component only supports 1:1, 3:2, 4:3, 16:9, 3:4, and 2:3, and has no auto option. To cover the full preset list above (including 7:5, 5:4, and Auto), the wrapper's aspect ratio and the image inside it are hand-rolled with aspect-[…] utilities and a plain <img>, not Thumbnail:
const ASPECT_CLASSES = {
auto: '',
'16:9': 'aspect-[16/9]',
'3:2': 'aspect-[3/2]',
'7:5': 'aspect-[7/5]',
'4:3': 'aspect-[4/3]',
'5:4': 'aspect-[5/4]',
'1:1': 'aspect-square',
'3:4': 'aspect-[3/4]',
}Image
The image has its own Auto/Fill setting, independent of the wrapper's aspect ratio (and hidden for Floating, which is always Fill since the image is the background). Auto keeps the image at its natural file ratio (w-full h-auto), so a portrait photo doesn't get cropped, it just sets its own height within the layout. Fill stretches the image across whatever box the layout gives it and crops it with object-cover (size-full object-cover), the usual hero-image treatment.
The image also gets the same SSR-safe fallback as Thumbnail: instead of reusing that component, the reconstruction hand-rolls the same complete / naturalWidth check (see Thumbnail's source), so a missing or broken src swaps to a centered MaterialSymbol icon instead of a blank or broken image, without relying on an error event that might never fire (a cached 404 doesn't refire onError after hydration):
function ImageArea({ src, alt, fallbackIcon, fill }) {
const [errored, setErrored] = React.useState(false)
const imgRef = React.useRef(null)
React.useEffect(() => {
setErrored(false)
const node = imgRef.current
if (node?.complete && node.naturalWidth === 0) setErrored(true)
}, [src])
return errored ? (
<MaterialSymbol name={fallbackIcon} />
) : (
<img
alt={alt}
className={fill ? 'size-full object-cover' : 'h-auto w-full'}
onError={() => setErrored(true)}
ref={imgRef}
src={src}
/>
)
}Theming
The wrapper themes the same way as the Quick Actions block's card: a Style (Soft, Surface, Outlined, Solid, Ghost) and a Colour (Brand, Gray, Red, Green, Blue, Orange) pick semantic-token classes, and Scaling, Spacing, and Radius come from a scoped <Theme> instead of per-element size classes:
import { Theme } from '@epilot/spark-ui/theme'
// Small -> '90%', Medium -> '100%', Large -> '110%'
;<Theme className="contents" radius="large" scaling="100%" spacing="roomy">
<Teaser />
</Theme>The content box is its own themed surface, with the same five styles plus a sixth, None, that keeps it fully transparent so the title, subtitle, and button just inherit the wrapper's colour instead of drawing a second surface. Its colour can be any of the six wrapper families, independent of the wrapper's (an accent-coloured wrapper can hold a plain gray content card, or vice versa), plus a seventh, Background, that fills the box with the opaque neutral page surface (bg-gray-1/bg-gray-2) rather than a tinted family. Background is the readable-scrim case: on a Floating layout, an opaque panel keeps the text legible over the image, which the translucent base-gray tokens the other colours use cannot guarantee.
See Card for a themeable surface with these same style and colour ideas built in, Theme Playground for the full set of theme knobs, and Button for the call-to-action's prop reference.