feat(blox): add steps block

Steps block:
- Three layouts: vertical (stacked with connector line), horizontal
  (side-by-side, connector above), timeline (alternating cards on centre
  line)
- Three marker styles: number (decimal/padded/roman/alpha), icon, dot
- Layout-aware marker sizing - bare 40px icon for horizontal, tinted
  circle for vertical/timeline
- Staggered IntersectionObserver entrance animation
- Code span rendering in step body text
- Added to startup-landing-page template as a horizontal how-it-works
  section
This commit is contained in:
George Cushen committed 2026-05-02 03:08:09 +01:00
1 parent 4fd86541d0
commit 23e0bb1269
8 files changed
+583 -3

No files matched your search

+1 -1
View File
@@ -85,7 +85,7 @@ function LogoItem({item, idx, logoStyle, logoSize, icon_svgs, item_images, padY
{visual}
</a>
) : (
<div class={cls} aria-label={item.name || undefined}>{visual}</div>
<div class={cls} role="img" aria-label={item.name || undefined}>{visual}</div>
);
}
+2 -1
View File
@@ -20,6 +20,7 @@ function BillingToggle({billing, setBilling, toggle}) {
{toggle.monthly_label || "Monthly"}
</span>
<button
type="button"
role="switch"
aria-checked={isYearly}
onClick={() => setBilling(isYearly ? "monthly" : "yearly")}
@@ -176,7 +177,7 @@ function PricingTier({tier, billing, icon_svgs}) {
);
}
export const PricingBlock = ({content = {}, design = {}, icon_svgs = {}}) => {
export const PricingBlock = ({content = {}, icon_svgs = {}}) => {
const {title, subtitle, billing_toggle = {}, tiers = []} = content;
const hasYearlyPrices = tiers.some(
+15
View File
@@ -0,0 +1,15 @@
import {render} from "preact";
import {StepsBlock} from "./component.jsx";
function renderStepsBlocks() {
const blocks = document.querySelectorAll('[data-block-type="steps"]');
blocks.forEach((block) => {
const propsData = block.dataset.props;
if (propsData) {
const props = JSON.parse(propsData);
render(<StepsBlock {...props} />, block);
}
});
}
renderStepsBlocks();
+513
View File
@@ -0,0 +1,513 @@
import {useEffect, useRef} from "preact/hooks";
import {Icon} from "../../shared/components/Icon.jsx";
function renderText(text) {
if (!text) return "";
return String(text)
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.*?)\*/g, "<em>$1</em>")
.replace(/`(.*?)`/g, (_, code) => `<code class="px-1.5 py-0.5 rounded font-mono text-sm bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">${code}</code>`);
}
function toRoman(n) {
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
const syms = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"];
let r = "";
vals.forEach((v, i) => {
while (n >= v) {
r += syms[i];
n -= v;
}
});
return r;
}
function markerLabel(idx, numbering) {
const n = idx + 1;
if (numbering === "roman") return toRoman(n);
if (numbering === "alpha") return String.fromCharCode(64 + n);
if (numbering === "padded") return String(n).padStart(2, "0");
return String(n);
}
// size: "sm" (vertical/timeline — marker is secondary to text content)
// "lg" (horizontal desktop — marker is the primary focal point)
function StepMarker({idx, iconSvg, markerStyle, numbering, size = "sm"}) {
if (markerStyle === "dot") {
return <div class="w-3.5 h-3.5 rounded-full bg-primary-600 dark:bg-primary-400 flex-shrink-0 mt-1.5" />;
}
// Horizontal icon — no circle, icon speaks for itself.
// The w-14 h-14 wrapper is kept for layout consistency (connector line math, justify-around).
if (markerStyle === "icon" && iconSvg && size === "lg") {
return (
<div class="w-14 h-14 flex items-center justify-center text-primary-600 dark:text-primary-400 flex-shrink-0">
<Icon svg={iconSvg} attributes={{class: "inline-block", style: "height:2.5rem;width:auto"}} />
</div>
);
}
const dim = size === "lg" ? "w-14 h-14 text-base" : "w-10 h-10 text-sm";
const iconH = size === "lg" ? "1.75rem" : "1.25rem";
const base = `${dim} flex items-center justify-center rounded-full flex-shrink-0 font-bold`;
// Vertical/timeline icon — keep circle for visual anchoring against the connector line
if (markerStyle === "icon" && iconSvg) {
return (
<div class={`${base} bg-primary-100 dark:bg-primary-900/50 text-primary-700 dark:text-primary-300`}>
<Icon svg={iconSvg} attributes={{class: "inline-block", style: `height:${iconH};width:auto`}} />
</div>
);
}
return (
<div class={`${base} bg-primary-600 dark:bg-primary-500 text-white`}>
{markerLabel(idx, numbering)}
</div>
);
}
function StepBody({step, imgData}) {
const isExtCta =
step.cta?.url &&
(step.cta.url.startsWith("http://") || step.cta.url.startsWith("https://"));
return (
<>
{(step.badge || step.date) && (
<div class="flex items-center gap-2 mb-2">
{step.date && (
<span class="text-xs font-semibold text-primary-600 dark:text-primary-400 uppercase tracking-wide">
{step.date}
</span>
)}
{step.badge && (
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-primary-100 dark:bg-primary-900/60 text-primary-700 dark:text-primary-300">
{step.badge}
</span>
)}
</div>
)}
{step.title && (
<h3
class="text-lg font-semibold text-gray-900 dark:text-white mb-2"
dangerouslySetInnerHTML={{__html: renderText(step.title)}}
/>
)}
{step.text && (
<p
class="text-gray-500 dark:text-gray-400 leading-relaxed"
dangerouslySetInnerHTML={{__html: renderText(step.text)}}
/>
)}
{imgData && (
<img
src={imgData.src}
alt={step.title || ""}
class="mt-4 rounded-xl shadow-sm w-full object-cover max-h-60"
/>
)}
{step.cta?.text && step.cta?.url && (
<a
href={step.cta.url}
class="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
{...(isExtCta ? {target: "_blank", rel: "noopener noreferrer"} : {})}
>
{step.cta.text}
<span aria-hidden="true">→</span>
</a>
)}
</>
);
}
// ─── Vertical ──────────────────────────────────────────────────────────────────
function VerticalSteps({steps, markerStyle, numbering, connector, icon_svgs, item_images}) {
const isDashed = connector === "dashed";
return (
<ol>
{steps.map((step, i) => {
const isLast = i === steps.length - 1;
const iconSvg = step.icon ? (icon_svgs?.[step.icon] ?? null) : null;
const imgData = item_images?.[String(i)] ?? null;
return (
<li key={i} class="step-item flex gap-5">
<div class="flex flex-col items-center flex-shrink-0">
<StepMarker
idx={i}
iconSvg={iconSvg}
markerStyle={markerStyle}
numbering={numbering}
/>
{!isLast && connector !== "none" && (
<div
class={`flex-1 w-0.5 my-2 ${
isDashed
? "border-l-2 border-dashed border-gray-200 dark:border-gray-700"
: "bg-gray-200 dark:bg-gray-700"
}`}
style="min-height: 1.25rem"
/>
)}
</div>
<div class={`flex-1 min-w-0 pt-0.5 ${isLast ? "pb-0" : "pb-10"}`}>
<StepBody step={step} imgData={imgData} />
</div>
</li>
);
})}
</ol>
);
}
// ─── Horizontal ────────────────────────────────────────────────────────────────
function HorizontalSteps({steps, markerStyle, numbering, connector, icon_svgs}) {
const isDashed = connector === "dashed";
return (
<>
{/* Mobile: vertical stack */}
<ol class="sm:hidden space-y-8">
{steps.map((step, i) => {
const iconSvg = step.icon ? (icon_svgs?.[step.icon] ?? null) : null;
return (
<li key={i} class="step-item flex gap-4 items-start">
<StepMarker
idx={i}
iconSvg={iconSvg}
markerStyle={markerStyle}
numbering={numbering}
/>
<div class="pt-0.5 flex-1 min-w-0">
{step.title && (
<h3
class="font-semibold text-gray-900 dark:text-white mb-1"
dangerouslySetInnerHTML={{__html: renderText(step.title)}}
/>
)}
{step.text && (
<p
class="text-sm text-gray-500 dark:text-gray-400"
dangerouslySetInnerHTML={{__html: renderText(step.text)}}
/>
)}
{step.cta?.text && step.cta?.url && (
<a
href={step.cta.url}
class="inline-flex items-center gap-1 mt-2 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline"
>
{step.cta.text} →
</a>
)}
</div>
</li>
);
})}
</ol>
{/* Desktop: horizontal layout with connecting line */}
<div class="hidden sm:block">
{/* Markers row — absolute line behind, markers on top */}
{/* top-7/left-7/right-7 = half the w-14 h-14 (56px) marker, centres the line on the circles */}
<div class="relative flex justify-around mb-6">
{connector !== "none" && (
<div
class={`absolute top-7 left-7 right-7 h-0.5 ${
isDashed
? "border-t-2 border-dashed border-gray-200 dark:border-gray-700"
: "bg-gray-200 dark:bg-gray-700"
}`}
/>
)}
{steps.map((step, i) => {
const iconSvg = step.icon ? (icon_svgs?.[step.icon] ?? null) : null;
return (
<div key={i} class="step-item relative z-10 flex flex-col items-center">
<StepMarker
idx={i}
iconSvg={iconSvg}
markerStyle={markerStyle}
numbering={numbering}
size="lg"
/>
</div>
);
})}
</div>
{/* Content row — aligned under each marker */}
<div class="flex">
{steps.map((step, i) => (
<div key={i} class="flex-1 text-center px-3">
{step.badge && (
<span class="inline-block mb-1.5 px-2 py-0.5 text-xs font-medium rounded-full bg-primary-100 dark:bg-primary-900/60 text-primary-700 dark:text-primary-300">
{step.badge}
</span>
)}
{step.title && (
<h3
class="text-base font-bold text-gray-900 dark:text-white mb-1.5"
dangerouslySetInnerHTML={{__html: renderText(step.title)}}
/>
)}
{step.text && (
<p
class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed"
dangerouslySetInnerHTML={{__html: renderText(step.text)}}
/>
)}
{step.cta?.text && step.cta?.url && (
<a
href={step.cta.url}
class="inline-flex items-center justify-center gap-1 mt-2 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline"
>
{step.cta.text} →
</a>
)}
</div>
))}
</div>
</div>
</>
);
}
// ─── Timeline ──────────────────────────────────────────────────────────────────
function TimelineSteps({steps, markerStyle, numbering, icon_svgs, item_images}) {
return (
<div class="relative">
{/* Center vertical line, desktop only */}
<div class="absolute left-1/2 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700 -translate-x-1/2 hidden md:block" />
<ol class="space-y-10 md:space-y-12">
{steps.map((step, i) => {
const isLeft = i % 2 === 0;
const iconSvg = step.icon ? (icon_svgs?.[step.icon] ?? null) : null;
const imgData = item_images?.[String(i)] ?? null;
const isExtCta =
step.cta?.url &&
(step.cta.url.startsWith("http://") || step.cta.url.startsWith("https://"));
return (
<li
key={i}
class={`step-item flex items-start gap-4 md:gap-0 ${
isLeft ? "md:flex-row" : "md:flex-row-reverse"
}`}
>
{/* Content card */}
<div
class={`flex-1 md:max-w-[calc(50%-2rem)] ${
isLeft ? "md:pr-8" : "md:pl-8"
}`}
>
<div class="bg-white dark:bg-gray-800/80 rounded-2xl border border-gray-200 dark:border-gray-700 p-5 shadow-sm hover:shadow-md transition-shadow duration-300">
{(step.badge || step.date) && (
<div class="flex items-center gap-2 mb-3">
{step.date && (
<span class="text-xs font-semibold text-primary-600 dark:text-primary-400 uppercase tracking-wide">
{step.date}
</span>
)}
{step.badge && (
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-primary-100 dark:bg-primary-900/60 text-primary-700 dark:text-primary-300">
{step.badge}
</span>
)}
</div>
)}
{step.title && (
<h3
class="text-base font-semibold text-gray-900 dark:text-white mb-2"
dangerouslySetInnerHTML={{__html: renderText(step.title)}}
/>
)}
{step.text && (
<p
class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed"
dangerouslySetInnerHTML={{__html: renderText(step.text)}}
/>
)}
{imgData && (
<img
src={imgData.src}
alt={step.title || ""}
class="mt-3 rounded-lg w-full object-cover max-h-48"
/>
)}
{step.cta?.text && step.cta?.url && (
<a
href={step.cta.url}
class="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-primary-600 dark:text-primary-400 hover:underline"
{...(isExtCta ? {target: "_blank", rel: "noopener noreferrer"} : {})}
>
{step.cta.text} →
</a>
)}
</div>
</div>
{/* Center marker */}
<div class="relative z-10 flex-shrink-0">
<StepMarker
step={step}
idx={i}
iconSvg={iconSvg}
markerStyle={markerStyle}
numbering={numbering}
/>
</div>
{/* Spacer on opposite side (desktop) */}
<div class="hidden md:block flex-1 md:max-w-[calc(50%-2rem)]" />
</li>
);
})}
</ol>
</div>
);
}
// ─── Staggered reveal animation ────────────────────────────────────────────────
const STEP_ANIMATION_CSS = `
.step-item {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.step-item.step-visible {
opacity: 1;
transform: translateY(0);
}
.step-item:nth-child(1) { transition-delay: 0ms; }
.step-item:nth-child(2) { transition-delay: 100ms; }
.step-item:nth-child(3) { transition-delay: 200ms; }
.step-item:nth-child(4) { transition-delay: 300ms; }
.step-item:nth-child(5) { transition-delay: 400ms; }
.step-item:nth-child(6) { transition-delay: 500ms; }
`;
function useStepAnimation(ref) {
useEffect(() => {
const container = ref.current;
if (!container) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const items = container.querySelectorAll(".step-item");
if (reduced) {
items.forEach((el) => {
el.classList.add("step-visible");
});
return;
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("step-visible");
observer.unobserve(entry.target);
}
});
},
{threshold: 0.15},
);
items.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight * 0.95) {
el.classList.add("step-visible");
} else {
observer.observe(el);
}
});
return () => observer.disconnect();
}, []);
}
// ─── Main block ────────────────────────────────────────────────────────────────
export const StepsBlock = ({content = {}, design = {}, icon_svgs = {}, item_images = {}}) => {
const containerRef = useRef(null);
useStepAnimation(containerRef);
const steps = Array.isArray(content.items)
? content.items
: Array.isArray(content.steps)
? content.steps
: [];
const {title, subtitle, text} = content;
const layout = design.layout || "vertical";
const markerStyle = design.marker_style || "number";
const numbering = design.numbering || "decimal";
const connector = design.connector || "line";
const isCentered = layout === "horizontal" || layout === "timeline";
const maxW =
layout === "horizontal" ? "max-w-5xl" : layout === "timeline" ? "max-w-4xl" : "max-w-2xl";
return (
<div class="py-12 sm:py-16 px-4 sm:px-6 lg:px-8" ref={containerRef}>
<style>{STEP_ANIMATION_CSS}</style>
<div class={`mx-auto ${maxW}`}>
{(title || subtitle || text) && (
<div class={`mb-10 sm:mb-14 ${isCentered ? "text-center" : ""}`}>
{subtitle && (
<p class="text-sm font-semibold uppercase tracking-widest text-primary-600 dark:text-primary-400 mb-3">
{subtitle}
</p>
)}
{title && (
<h2
class="text-3xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-4xl mb-3"
dangerouslySetInnerHTML={{__html: renderText(title)}}
/>
)}
{text && (
<p
class={`text-lg text-gray-500 dark:text-gray-400 ${isCentered ? "mx-auto max-w-2xl" : "max-w-2xl"}`}
dangerouslySetInnerHTML={{__html: renderText(text)}}
/>
)}
</div>
)}
{layout === "horizontal" && (
<HorizontalSteps
steps={steps}
markerStyle={markerStyle}
numbering={numbering}
connector={connector}
icon_svgs={icon_svgs}
/>
)}
{layout === "timeline" && (
<TimelineSteps
steps={steps}
markerStyle={markerStyle}
numbering={numbering}
icon_svgs={icon_svgs}
item_images={item_images}
/>
)}
{layout !== "horizontal" && layout !== "timeline" && (
<VerticalSteps
steps={steps}
markerStyle={markerStyle}
numbering={numbering}
connector={connector}
icon_svgs={icon_svgs}
item_images={item_images}
/>
)}
</div>
</div>
);
};
+15
View File
@@ -0,0 +1,15 @@
{
"id": "steps",
"name": "Steps",
"version": "1.0.0",
"license": "MIT",
"category": "content",
"intent": ["inform", "convert"],
"verticals": ["startup", "saas", "agency", "dev-portfolio", "lab", "academic-cv"],
"tags": ["steps", "process", "how-it-works", "timeline", "onboarding", "tutorial", "roadmap", "milestones"],
"description": "Numbered process steps with vertical, horizontal, and timeline layouts. Use for 'How it works', onboarding flows, installation guides, feature walkthroughs, and project timelines. Supports icons, badges, dates, images, and per-step CTAs.",
"author": "Hugo Blox",
"homepage": "https://hugoblox.com/blocks/",
"repository": "https://github.com/HugoBlox/kit",
"keywords": ["hugo", "steps", "process", "timeline", "how-it-works", "onboarding"]
}
@@ -148,6 +148,23 @@
{{ $props = merge $props (dict "icon_svgs" $icon_svgs) }}
{{ end }}
{{/* Resolve per-step icons (steps block — semantic 'steps' key alias) */}}
{{ with $block.content.steps }}
{{ range . }}
{{ with .icon }}
{{ if not (index $icon_svgs .) }}
{{ $icon_data := partial "functions/get_icon_data" (dict "name" .) }}
{{ if $icon_data }}
{{ $icon_svgs = merge $icon_svgs (dict . $icon_data) }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ if gt (len $icon_svgs) 0 }}
{{ $props = merge $props (dict "icon_svgs" $icon_svgs) }}
{{ end }}
{{/* Resolve CTA icons from pricing tiers (content.tiers[*].cta.icon) */}}
{{ with $block.content.tiers }}
{{ range . }}
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { readFileSync, readdirSync, statSync } from "node:fs";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
const REQUIRED = ["tailwindcss", "@tailwindcss/cli"];
@@ -86,6 +86,25 @@ sections:
# Reduce spacing
spacing:
padding: ["1rem", 0, "1rem", 0]
- block: steps
content:
title: Get started in minutes
text: No credit card required. Deploy to GitHub Pages in one click.
items:
- title: Choose a template
text: Pick from our library of production-ready templates — academic CV, startup landing page, research lab, and more.
icon: rectangle-stack
- title: Customise your content
text: Edit in simple YAML and Markdown. No coding required — everything configured through intuitive parameters.
icon: pencil-square
- title: Deploy everywhere
text: Push to GitHub and your site deploys automatically. Free hosting on GitHub Pages, Netlify, or Vercel.
icon: rocket-launch
design:
layout: horizontal
marker_style: icon
connector: none
- block: features
id: features
content: