Zoeken...  ⌘K GitHub

CtaSticky CTA

Vaste CTA-balk onderaan de viewport die na een scroll-drempel inschuift. Dismissible.

/cta-sticky
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