# Sticky Banner (Moonarc)

An announcement ribbon that sticks to the top of the page and folds away when dismissed, the content below sliding up on the preset curve. Zero JavaScript: a non-modal dialog closed by a form with method="dialog", a grid row that :has() collapses, and an exit through allow-discrete. remember inlines 286 B raw that keep it closed across pages.

- Import: `import StickyBanner from '@moonarc/core/StickyBanner'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/sticky-banner.json`
- Tier A · category ui · trigger load, click
- Readout: `<StickyBanner id="launch" remember>`
- Browser support: newly (Chrome 117 · Firefox 129 · Safari 17.4); elsewhere: without allow-discrete the ribbon disappears at once; the row still folds on :has()
- Measured cost: 0 B JS (CSS 6.1 kB raw)
- Page: https://moonarc.dev/components/sticky-banner/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | none | Dialog id; with remember, the localStorage key is ma-banner:<id>. Change it for a new announcement. |
| `dismissible` | `boolean` | `true` | Render the close button. |
| `remember` | `boolean` | `false` | Keep a dismissal in localStorage and never render the ribbon again on this browser: an inline script of 286 B raw (measured from the string that is inlined) that runs where the banner is, before paint, so nothing flashes. |
| `href` | `string` | none | A call-to-action link. |
| `cta` | `string` | `'Learn more'` | Its label. |
| `color` | `string` | none | Ribbon colour; default the ink token. |
| `textColor` | `string` | none | Text colour; default the panel token. |
| `label` | `string` | `'Announcement'` | Accessible name of the dialog. |

## Usage

```astro
<StickyBanner id="launch" href="/changelog/" cta="See what shipped">
  <strong>New</strong>: dark mode for every chart.
</StickyBanner>

<!-- stays dismissed on the next visit -->
<StickyBanner id="launch" remember>…</StickyBanner>
```

## Reduced motion

The row folds in 150 ms without the slide.

## With ClientRouter

Native; nothing to rebind. Without remember every page renders the ribbon again; with it the inline script closes it before paint on every page. The script carries data-astro-rerun: ClientRouter skips an inline script whose text it has already run, and this one is the same on every page.

## Craft

- A non-modal <dialog open> gives a close button for free (a form with method="dialog") and a state (the open attribute) that CSS can read with :has(). No class, no script.
- The fold is the Accordion trick turned around: the wrapper is a grid whose row goes from 1fr to 0fr, so the page below slides up on the preset curve instead of jumping when the ribbon leaves.
- The ribbon itself slides up and fades on the exit curve while its display transitions with allow-discrete, so it is still there to be animated.
- remember is the only case that needs a script and it is inline on purpose: it has to run before the first paint of the page it is on, like ThemeScript, and it is 286 B raw, measured from the same string that is inlined.
- Sticky at z-index 30 sits under a site header at 40 and over content: the ribbon belongs to the page, not the chrome.

## Replaces

- Banner (Aceternity)
- AnnouncementBar (Tailwind Plus)
- Alert dismissible (shadcn / Radix)

## Source

```astro
---
/**
 * StickyBanner — an announcement ribbon that sticks to the top and folds
 * away when dismissed, zero JS.
 *
 * The ribbon is a non-modal <dialog open>, so the close button is a form
 * with method="dialog" — every browser closes it, no script. The wrapper
 * is a grid whose only row goes 1fr → 0fr when :has() sees the dialog
 * closed, and the ribbon slides up as it leaves through allow-discrete, so
 * the page below moves up on the preset curve rather than jumping.
 * Without `remember` it comes back on the next page; `remember` inlines
 * ~290 B that read localStorage before paint (no flash) and write it on
 * close, marked data-astro-rerun so ClientRouter runs it on every page.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Dialog id and, with remember, the storage key. */
  id: string;
  /** Show the close button. */
  dismissible?: boolean;
  /** Remember a dismissal in localStorage (ma-banner:<id>) and never render it again — an inline script, measured on the readout. */
  remember?: boolean;
  /** Optional call to action. */
  href?: string;
  cta?: string;
  /** Ribbon colours; default ink on panel text. */
  color?: string;
  textColor?: string;
  /** Accessible name. */
  label?: string;
}

const { id, dismissible = true, remember = false, href, cta = 'Learn more', color, textColor, label = 'Announcement', class: className, style, ...rest } = Astro.props;
const vars = [color && `--ma-banner-bg:${color}`, textColor && `--ma-banner-fg:${textColor}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
/** Runs where it is rendered, before the next paint: a remembered dismissal closes the dialog and skips the fold; every close is stored. */
const REMEMBER = `(function(){var s=document.currentScript,w=s.previousElementSibling,d=w.querySelector('dialog'),K='ma-banner:'+d.id;try{if(localStorage.getItem(K)){w.setAttribute('data-gone','');d.close()}}catch(e){}d.addEventListener('close',function(){try{localStorage.setItem(K,'1')}catch(e){}})})()`;
---

<div class:list={['ma-banner', className]} style={vars || undefined} {...rest}>
  <dialog open id={id} class="ma-banner__dialog" aria-label={label}>
    <div class="ma-banner__inner">
      <p class="ma-banner__text"><slot /></p>
      {href && <a class="ma-banner__cta" href={href}>{cta} <span aria-hidden="true">→</span></a>}
      {dismissible && (
        <form method="dialog" class="ma-banner__close">
          <button type="submit" aria-label="Dismiss">
            <svg aria-hidden="true" viewBox="0 0 16 16" width="14" height="14"><path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /></svg>
          </button>
        </form>
      )}
    </div>
  </dialog>
</div>
{/* data-astro-rerun: ClientRouter skips an inline script whose text the page it leaves already ran, and this one is the
    same on every page, so without it the next page rendered the ribbon open again */}
{remember && <script is:inline data-astro-rerun set:html={REMEMBER} />}

<style is:global>
  @layer components {
    /* the wrapper is the sticky element and the fold: one grid row from 1fr to 0fr */
    :where(.ma-banner) {
      position: sticky;
      top: 0;
      z-index: 30;
      display: grid;
      grid-template-rows: 1fr;
      transition: grid-template-rows var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-banner:has(.ma-banner__dialog:not([open]))) {
      grid-template-rows: 0fr;
    }
    :where(.ma-banner[data-gone]) {
      transition: none;
    }
    :where(.ma-banner__dialog) {
      position: static;
      width: 100%;
      max-width: none;
      min-height: 0;
      margin: 0;
      padding: 0;
      border: 0;
      background: none;
      color: inherit;
      overflow: clip;
      opacity: 1;
      translate: 0 0;
      transition:
        opacity var(--ma-duration-fast) var(--ma-ease-out),
        translate var(--ma-duration) var(--ma-ease-in),
        display var(--ma-duration) allow-discrete;
    }
    :where(.ma-banner__dialog:not([open])) {
      opacity: 0;
      translate: 0 -100%;
    }
    :where(.ma-banner__inner) {
      position: relative;
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      justify-content: center;
      gap: 0.25rem 1rem;
      padding: 0.625rem 3rem;
      background: var(--ma-banner-bg, var(--ma-ink));
      color: var(--ma-banner-fg, var(--ma-panel));
      font-size: 0.875rem;
      line-height: 1.4;
      text-align: center;
    }
    :where(.ma-banner__text) {
      margin: 0;
    }
    :where(.ma-banner__cta) {
      color: inherit;
      font-weight: 600;
      text-decoration: underline;
      text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
      text-underline-offset: 0.2em;
      white-space: nowrap;
      transition: text-decoration-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-banner__cta:hover) {
      text-decoration-color: currentColor;
    }
    :where(.ma-banner__close) {
      position: absolute;
      top: 50%;
      right: 0.75rem;
      translate: 0 -50%;
    }
    :where(.ma-banner__close button) {
      display: grid;
      place-items: center;
      width: 1.75rem;
      height: 1.75rem;
      border: 0;
      border-radius: 999px;
      background: none;
      color: inherit;
      cursor: pointer;
      opacity: 0.7;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out), background-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-banner__close button:hover) {
      opacity: 1;
      background: color-mix(in srgb, currentColor 14%, transparent);
    }
    :where(.ma-banner__close button:focus-visible),
    :where(.ma-banner__cta:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 2px;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-banner),
      :where(.ma-banner__dialog) {
        transition-duration: var(--ma-duration-fast);
        transition-timing-function: linear;
      }
      :where(.ma-banner__dialog:not([open])) {
        translate: 0 0;
      }
    }
  }
</style>

```
