HeroProduct Hero
Product-hero: tekstkolom links (label-pill, kop met accent, sub, feature-lijst, sterren-rating, twee CTA's) en rechts een browser-frame met screenshot.
src/components/hero/HeroProduct.astro
---
/**
* HeroProduct
* Product-hero: tekstkolom links (label-pill, kop met cursief accent, sub,
* feature-lijst met vinkjes, sterren-rating, twee CTA's) en rechts een
* browser-frame met productscreenshot. Content ligt op de pagina-rail.
*
* Props: zie interface. Alle copy is prop-driven.
*/
interface Props {
label?: string;
headline: string;
accent?: string;
sub?: string;
features?: string[];
rating?: number;
reviewCount?: number;
primaryCta?: { label: string; href: string };
ghostCta?: { label: string; href: string };
screenshot?: { src: string; alt?: string };
}
const {
label,
headline,
accent,
sub,
features = [],
rating,
reviewCount,
primaryCta,
ghostCta,
screenshot,
} = Astro.props;
const fullStars = rating ? Math.floor(rating) : 0;
const hasHalf = rating ? rating - fullStars >= 0.25 && rating - fullStars < 0.75 : false;
const starPath = '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';
---
<section class:list={['bl-section', 'hpd__root']} aria-label="Hero">
<div class="bl-inner hpd__container">
<div class="hpd__left">
{label && <span class="hpd__label">{label}</span>}
<h1 class="hpd__headline">{headline}{accent && <em> {accent}</em>}</h1>
{sub && <p class="hpd__sub">{sub}</p>}
{features.length > 0 && (
<ul class="hpd__features" aria-label="Features">
{features.map((feature) => (
<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"></path></svg>
</span>
{feature}
</li>
))}
</ul>
)}
{rating && (
<div class="hpd__rating" aria-label={`Beoordeling: ${rating} van 5${reviewCount ? `, ${reviewCount} reviews` : ''}`}>
<span class="hpd__stars" aria-hidden="true">
{Array.from({ length: fullStars }).map(() => (
<span class="hpd__star hpd__star--full"><svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d={starPath}></path></svg></span>
))}
{hasHalf && (
<span class="hpd__star hpd__star--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><stop offset="50%" stop-color="transparent"></stop></linearGradient></defs><path d={starPath} fill="url(#hpd-half)" stroke="currentColor" stroke-width="0.5"></path></svg></span>
)}
</span>
<span class="hpd__rating-text"><strong>{rating}</strong>{reviewCount && <> ({reviewCount} reviews)</>}</span>
</div>
)}
{(primaryCta || ghostCta) && (
<div class="hpd__actions">
{primaryCta && <a href={primaryCta.href} class="hpd__cta hpd__cta--primary">{primaryCta.label}</a>}
{ghostCta && <a href={ghostCta.href} class="hpd__cta hpd__cta--ghost">{ghostCta.label}</a>}
</div>
)}
</div>
{screenshot && (
<div class="hpd__right">
<div class="hpd__frame" aria-hidden="true">
<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>
<div class="hpd__frame-screen">
<img data-allow-img src={screenshot.src} alt={screenshot.alt ?? ''} class="hpd__screenshot" loading="eager" fetchpriority="high" decoding="async" />
</div>
</div>
</div>
)}
</div>
</section>
<style>
.hpd__root{background:var(--color-bg, #fff);min-height:100svh;display:flex;align-items:center;overflow:hidden}
.hpd__container{display:grid;grid-template-columns:1fr 1fr;gap:clamp(3rem, 6vw, 5rem);align-items:center}
.hpd__left{display:flex;flex-direction:column;gap:1.5rem}
.hpd__label{display:inline-block;font-size:.8125rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;background:color-mix(in srgb,var(--color-accent, #6366f1) 12%,transparent);color:var(--color-accent, #6366f1);padding:.3125rem .875rem;border-radius:999px;border:1px solid color-mix(in srgb,var(--color-accent, #6366f1) 30%,transparent);width:fit-content}
.hpd__headline{font-size:clamp(2.5rem,4vw,4.5rem);font-weight:900;line-height:1.06;letter-spacing:-.03em;color:var(--color-primary, #0a0a0a);margin:0}
.hpd__headline em{font-style:normal;color:var(--color-accent, #6366f1)}
.hpd__sub{font-size:clamp(1rem,1.3vw,1.125rem);color:var(--color-muted, #6b7280);line-height:1.7;margin:0;max-width:46ch}
.hpd__features{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.625rem}
.hpd__feature-item{display:flex;align-items:center;gap:.625rem;font-size:.9375rem;color:var(--color-primary, #0a0a0a);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, #6366f1) 14%,transparent);color:var(--color-accent, #6366f1);flex-shrink:0}
.hpd__rating{display:flex;align-items:center;gap:.5rem}
.hpd__stars{display:flex;gap:2px;color:#f59e0b}
.hpd__star{display:inline-flex;align-items:center}
.hpd__rating-text{font-size:.9375rem;color:#4b5563}
.hpd__rating-text strong{color:var(--color-primary, #0a0a0a)}
.hpd__actions{display:flex;flex-wrap:wrap;gap:.875rem;align-items:center;margin-top:.5rem}
.hpd__cta{display:inline-flex;align-items:center;justify-content:center;padding:.875rem 1.875rem;border-radius:var(--radius, .5rem);font-size:.9375rem;font-weight:600;text-decoration:none;white-space:nowrap;transition:opacity .2s,transform .2s}
.hpd__cta:hover{opacity:.88;transform:translateY(-1px)}
.hpd__cta--primary{background:var(--color-accent, #6366f1);color:#fff}
.hpd__cta--ghost{background:transparent;color:var(--color-primary, #0a0a0a);border:1.5px solid rgba(10,10,10,.18)}
.hpd__cta--ghost:hover{border-color:#0a0a0a66}
.hpd__right{display:flex;align-items:center;justify-content:center}
.hpd__frame{width:100%;max-width:640px;border-radius:calc(var(--radius, .5rem) * 1.75);overflow:hidden;background:#1a1a1a;border:1px solid rgba(255,255,255,.12);box-shadow:0 50px 100px -20px #00000040,0 20px 40px -10px #0003,0 0 0 1px #0000000d;transform:perspective(1200px) rotateY(-8deg) rotateX(2deg);transform-origin:center center;transition:transform .5s cubic-bezier(.16,1,.3,1)}
.hpd__frame:hover{transform:perspective(1200px) rotateY(-2deg) rotateX(.5deg)}
.hpd__frame-chrome{display:flex;align-items:center;gap:.5rem;padding:.625rem 1rem;background:#252525;border-bottom:1px solid rgba(255,255,255,.06)}
.hpd__dot{width:.6875rem;height:.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:.75rem}
.hpd__frame-url-bar{display:block;height:.4375rem;border-radius:999px;background:#ffffff1a;max-width:220px}
.hpd__frame-screen{width:100%;overflow:hidden;max-height:460px;background:#f0f0f0}
.hpd__screenshot{width:100%;height:100%;object-fit:cover;object-position:top;display:block}
@media (max-width:768px){
.hpd__container{grid-template-columns:1fr}
.hpd__frame{transform:none;max-width:100%}
.hpd__frame:hover{transform:none}
.hpd__frame-screen{max-height:320px}
}
</style>
Props
| Prop | Type | Default | Beschrijving |
|---|---|---|---|
label | string | - | Label-pill boven de kop |
headline * | string | - | H1-kop |
accent | string | - | Cursief accent direct na de kop |
sub | string | - | Ondertitel |
features | string[] | - | Feature-lijst met vinkjes |
rating | number | - | Sterren-rating (0-5) |
reviewCount | number | - | Aantal reviews |
primaryCta | { label: string; href: string } | - | Primaire knop |
ghostCta | { label: string; href: string } | - | Ghost-knop |
screenshot | { src: string; alt?: string } | - | Screenshot in browser-frame |
* = verplicht