CtaSticky CTA
Vaste CTA-balk onderaan de viewport die na een scroll-drempel inschuift. Dismissible.
src/components/cta/CtaSticky.astro
---
/**
* CtaSticky
* Vaste CTA-balk onderaan de viewport die na een scroll-drempel inschuift.
* Dismissible. De balk is bewust full-bleed (position:fixed); de inhoud zit
* in de gedeelde .bl-inner rail zodat tekst op dezelfde linker-pixel start.
*
* Props:
* - text: string boodschap links
* - cta: { label: string; href: string }
* - secondary?: { label: string; href: string }
* - bg?: 'dark' | 'accent' | 'white' default 'dark'
* - showAfter?: number scroll-px voor de balk verschijnt (default 400)
* - dismissible?: boolean default true
*/
interface Props {
text: string;
cta: { label: string; href: string };
secondary?: { label: string; href: string };
bg?: 'dark' | 'accent' | 'white';
showAfter?: number;
dismissible?: boolean;
}
const {
text,
cta,
secondary,
bg = 'dark',
showAfter = 400,
dismissible = true,
} = Astro.props;
const barId = `cts-bar-${Math.random().toString(36).slice(2, 8)}`;
---
<div
id={barId}
class:list={['cts__bar', `cts__bg-${bg}`]}
role="complementary"
aria-label="Sticky call to action"
data-show-after={showAfter}
data-dismissible={dismissible ? 'true' : 'false'}
>
<div class="bl-inner cts__inner">
<p class="cts__text">{text}</p>
<div class="cts__actions">
<a class="cts__btn" href={cta.href}>{cta.label}</a>
{secondary && <a class="cts__secondary" href={secondary.href}>{secondary.label}</a>}
{dismissible && (
<button class="cts__close" type="button" aria-label="Sluiten">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 2l12 12M14 2L2 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
</button>
)}
</div>
</div>
</div>
<script define:vars={{ barId }}>
(function () {
const bar = document.getElementById(barId);
if (!bar) return;
const showAfter = Number(bar.dataset.showAfter) || 400;
const dismissible = bar.dataset.dismissible === 'true';
let dismissed = false;
function show() { bar.classList.add('cts--visible'); }
function hide() { bar.classList.remove('cts--visible'); }
function onScroll() {
if (dismissed) return;
if (window.scrollY > showAfter) show();
else hide();
}
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
if (dismissible) {
const closeBtn = bar.querySelector('.cts__close');
if (closeBtn) {
closeBtn.addEventListener('click', function () {
dismissed = true;
hide();
window.removeEventListener('scroll', onScroll);
});
}
}
})();
</script>
<style>
.cts__bar{position:fixed;bottom:0;left:0;right:0;z-index:100;transform:translateY(100%);transition:transform .35s cubic-bezier(.4,0,.2,1)}
.cts__bar.cts--visible{transform:translateY(0)}
.cts__bg-dark{background:var(--color-primary,#0a0a0a);color:#fff}
.cts__bg-accent{background:var(--color-accent,#6366f1);color:#fff}
.cts__bg-white{background:var(--color-bg,#fff);color:var(--color-primary,#0a0a0a);border-top:1px solid rgba(0,0,0,.1);box-shadow:0 -4px 24px #00000014}
.cts__inner{display:flex;align-items:center;justify-content:space-between;gap:1.5rem;padding-block:1rem;flex-wrap:wrap}
.cts__text{margin:0;font-size:1rem;font-weight:500;flex:1;min-width:160px}
.cts__actions{display:flex;align-items:center;gap:1rem;flex-shrink:0}
.cts__btn{display:inline-flex;align-items:center;justify-content:center;padding:.625rem 1.25rem;border-radius:var(--radius,.5rem);background:#fff;color:var(--color-primary,#0a0a0a);font-size:1rem;font-weight:700;text-decoration:none;white-space:nowrap;transition:opacity .2s ease}
.cts__btn:hover{opacity:.88}
.cts__bg-white .cts__btn{background:var(--color-accent,#6366f1);color:#fff}
.cts__secondary{font-size:.9375rem;font-weight:600;color:inherit;text-decoration:none;opacity:.82;border-bottom:1px solid rgba(255,255,255,.4);padding-bottom:1px;white-space:nowrap;transition:opacity .2s ease}
.cts__secondary:hover{opacity:1}
.cts__bg-white .cts__secondary{color:var(--color-muted,#6b7280);border-color:#0003}
.cts__close{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:transparent;cursor:pointer;color:inherit;opacity:.7;flex-shrink:0;border-radius:4px;transition:opacity .2s ease}
.cts__close:hover{opacity:1}
@media (max-width:640px){
.cts__inner{flex-direction:column;align-items:flex-start;gap:.75rem}
.cts__btn{width:100%;justify-content:center}
.cts__actions{width:100%;justify-content:space-between}
}
</style>
Props
| Prop | Type | Default | Beschrijving |
|---|---|---|---|
text * | string | - | Boodschap links in de balk |
cta * | { label: string; href: string } | - | Primaire knop |
secondary | { label: string; href: string } | - | Secundaire link |
bg | 'dark' | 'accent' | 'white' | 'dark' | Achtergrondvariant |
showAfter | number | 400 | Scroll-px voor de balk verschijnt |
dismissible | boolean | true | Toon sluit-knop |
* = verplicht