HeroProduct Hero
SaaS-stijl product hero. Pure CSS browser frame mockup met screenshot, feature bullets en optionele review sterren.
src/components/hero/HeroProduct.astro
---
interface Props {
label?: string;
headline: string;
sub?: string;
ctaLabel?: string;
ctaHref?: string;
ctaSecondary?: string;
ctaSecondaryHref?: string;
screenshot: string;
features?: string[];
rating?: { value: number; count: number };
}
const {
label,
headline,
sub,
ctaLabel,
ctaHref = '#',
ctaSecondary,
ctaSecondaryHref = '#',
screenshot,
features = [],
rating,
} = Astro.props;
// Build star display (full, half, empty up to 5)
function buildStars(value: number): ('full' | 'half' | 'empty')[] {
const stars: ('full' | 'half' | 'empty')[] = [];
for (let i = 1; i <= 5; i++) {
if (value >= i) stars.push('full');
else if (value >= i - 0.5) stars.push('half');
else stars.push('empty');
}
return stars;
}
const stars = rating ? buildStars(rating.value) : [];
---
<section class="hpd__root" aria-label="Hero">
<div class="hpd__container">
<!-- Left: content -->
<div class="hpd__left">
{label && (
<span class="hpd__label">{label}</span>
)}
<h1 class="hpd__headline" set:html={headline} />
{sub && <p class="hpd__sub">{sub}</p>}
{features.length > 0 && (
<ul class="hpd__features" aria-label="Features">
{features.map((f) => (
<li class="hpd__feature-item">
<span class="hpd__check" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M2.5 7.5L5.5 10.5L11.5 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
{f}
</li>
))}
</ul>
)}
{rating && (
<div class="hpd__rating" aria-label={`Beoordeling: ${rating.value} van 5, ${rating.count} reviews`}>
<span class="hpd__stars" aria-hidden="true">
{stars.map((type) => (
<span class={`hpd__star hpd__star--${type}`}>
{type === 'full' && (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 1.25l1.854 3.756 4.146.603-3 2.923.708 4.128L8 10.513l-3.708 1.95.708-4.128-3-2.923 4.146-.603L8 1.25z"/>
</svg>
)}
{type === 'half' && (
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
<defs>
<linearGradient id="hpd-half">
<stop offset="50%" stop-color="currentColor"/>
<stop offset="50%" stop-color="transparent"/>
</linearGradient>
</defs>
<path d="M8 1.25l1.854 3.756 4.146.603-3 2.923.708 4.128L8 10.513l-3.708 1.95.708-4.128-3-2.923 4.146-.603L8 1.25z" fill="url(#hpd-half)" stroke="currentColor" stroke-width="0.5"/>
</svg>
)}
{type === 'empty' && (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="0.5" aria-hidden="true">
<path d="M8 1.25l1.854 3.756 4.146.603-3 2.923.708 4.128L8 10.513l-3.708 1.95.708-4.128-3-2.923 4.146-.603L8 1.25z" fill="rgba(0,0,0,0.08)"/>
</svg>
)}
</span>
))}
</span>
<span class="hpd__rating-text">
<strong>{rating.value.toFixed(1)}</strong>
({rating.count.toLocaleString('nl-NL')} reviews)
</span>
</div>
)}
<div class="hpd__actions">
{ctaLabel && (
<a href={ctaHref} class="hpd__cta hpd__cta--primary">
{ctaLabel}
</a>
)}
{ctaSecondary && (
<a href={ctaSecondaryHref} class="hpd__cta hpd__cta--ghost">
{ctaSecondary}
</a>
)}
</div>
</div>
<!-- Right: browser/laptop frame -->
<div class="hpd__right">
<div class="hpd__frame" aria-hidden="true">
<!-- Browser chrome -->
<div class="hpd__frame-chrome">
<span class="hpd__dot hpd__dot--red"></span>
<span class="hpd__dot hpd__dot--yellow"></span>
<span class="hpd__dot hpd__dot--green"></span>
<div class="hpd__frame-url" aria-hidden="true">
<span class="hpd__frame-url-bar"></span>
</div>
</div>
<!-- Screenshot -->
<div class="hpd__frame-screen">
<img
src={screenshot}
alt="Product screenshot"
class="hpd__screenshot"
loading="eager"
fetchpriority="high"
/>
</div>
</div>
</div>
</div>
</section>
<style>
:root {
--color-primary: #0a0a0a;
--color-accent: #6366f1;
--color-bg: #fff;
--color-muted: #6b7280;
--radius: 0.5rem;
}
/* ── Root ── */
.hpd__root {
background: var(--color-bg);
min-height: 100svh;
display: flex;
align-items: center;
overflow: hidden;
padding: clamp(4rem, 8vw, 7rem) clamp(1.5rem, 6vw, 6rem);
}
.hpd__container {
width: 100%;
max-width: 1440px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: clamp(3rem, 6vw, 7rem);
align-items: center;
}
/* ── Left column ── */
@keyframes hpd-fadein {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hpd__left {
display: flex;
flex-direction: column;
gap: 1.5rem;
animation: hpd-fadein 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.hpd__label {
display: inline-block;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
padding: 0.3125rem 0.875rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
width: fit-content;
}
.hpd__headline {
font-size: clamp(2.5rem, 4vw, 4.5rem);
font-weight: 900;
line-height: 1.06;
letter-spacing: -0.03em;
color: var(--color-primary);
margin: 0;
}
.hpd__headline em {
font-style: normal;
color: var(--color-accent);
}
.hpd__sub {
font-size: clamp(1rem, 1.3vw, 1.125rem);
color: var(--color-muted);
line-height: 1.7;
margin: 0;
max-width: 46ch;
}
/* Feature list */
.hpd__features {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.hpd__feature-item {
display: flex;
align-items: center;
gap: 0.625rem;
font-size: 0.9375rem;
color: var(--color-primary);
font-weight: 500;
}
.hpd__check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.375rem;
height: 1.375rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-accent);
flex-shrink: 0;
}
/* Rating */
.hpd__rating {
display: flex;
align-items: center;
gap: 0.5rem;
}
.hpd__stars {
display: flex;
gap: 2px;
color: #f59e0b;
}
.hpd__star {
display: inline-flex;
align-items: center;
}
.hpd__star--empty {
color: #d1d5db;
}
.hpd__rating-text {
font-size: 0.875rem;
color: var(--color-muted);
}
.hpd__rating-text strong {
color: var(--color-primary);
}
/* CTAs */
.hpd__actions {
display: flex;
flex-wrap: wrap;
gap: 0.875rem;
align-items: center;
margin-top: 0.5rem;
}
.hpd__cta {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.875rem 1.875rem;
border-radius: var(--radius);
font-size: 0.9375rem;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
transition:
opacity 0.2s,
transform 0.2s;
}
.hpd__cta:hover {
opacity: 0.88;
transform: translateY(-1px);
}
.hpd__cta--primary {
background: var(--color-accent);
color: #fff;
}
.hpd__cta--ghost {
background: transparent;
color: var(--color-primary);
border: 1.5px solid rgba(10, 10, 10, 0.18);
}
.hpd__cta--ghost:hover {
border-color: rgba(10, 10, 10, 0.4);
}
/* ── Right column: browser frame ── */
@keyframes hpd-framein {
from {
opacity: 0;
transform: perspective(1200px) rotateY(-12deg) rotateX(4deg) translateY(20px);
}
to {
opacity: 1;
transform: perspective(1200px) rotateY(-8deg) rotateX(2deg) translateY(0);
}
}
.hpd__right {
display: flex;
align-items: center;
justify-content: center;
}
.hpd__frame {
width: 100%;
max-width: 640px;
border-radius: calc(var(--radius) * 1.75);
overflow: hidden;
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 50px 100px -20px rgba(0, 0, 0, 0.25),
0 20px 40px -10px rgba(0, 0, 0, 0.2),
0 0 0 1px rgba(0, 0, 0, 0.05);
transform: perspective(1200px) rotateY(-8deg) rotateX(2deg);
transform-origin: center center;
will-change: transform;
animation: hpd-framein 0.9s 0.15s cubic-bezier(0.16, 1, 0.3, 1) both;
transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.hpd__frame:hover {
transform: perspective(1200px) rotateY(-2deg) rotateX(0.5deg);
}
/* Chrome bar */
.hpd__frame-chrome {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: #252525;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.hpd__dot {
width: 0.6875rem;
height: 0.6875rem;
border-radius: 50%;
flex-shrink: 0;
}
.hpd__dot--red { background: #ff5f57; }
.hpd__dot--yellow { background: #febc2e; }
.hpd__dot--green { background: #28c840; }
.hpd__frame-url {
flex: 1;
margin-left: 0.75rem;
}
.hpd__frame-url-bar {
display: block;
height: 0.4375rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
max-width: 220px;
}
/* Screenshot */
.hpd__frame-screen {
width: 100%;
overflow: hidden;
/* max height to avoid excessively tall frames */
max-height: 460px;
background: #f0f0f0;
}
.hpd__screenshot {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
display: block;
}
/* ── Mobile ── */
@media (max-width: 900px) {
.hpd__container {
grid-template-columns: 1fr;
}
.hpd__frame {
transform: none;
max-width: 100%;
}
.hpd__frame:hover {
transform: none;
}
.hpd__frame-screen {
max-height: 320px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
</style>
Props
| Prop | Type | Default | Beschrijving |
|---|---|---|---|
headline * | string | — | H1 — gebruik <em> voor accent |
screenshot * | string | — | Product screenshot URL (in browser frame) |
features | string[] | — | Feature bullets met vinkjes (3-4 ideaal) |
rating | { value: number; count: number } | — | Ster-rating + review count |
label | string | — | Pill label boven headline |
* = verplicht