Skip to content

Components / UI

Sticky Banner

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.

Live demo

2026-09-28: fix: what the audit of every product found.

What shipped
sitescroll · close

The ribbon stays. The page moves.

Dismiss it and the content below slides up on the preset curve as a grid row folds.

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 6.1 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 117 · Firefox 129 · Safari 17.4; needs dialog, has, allow-discrete, grid-rows-animation. Elsewhere: without allow-discrete the ribbon disappears at once; the row still folds on :has().

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import StickyBanner from '@moonarc/core/StickyBanner';
---
<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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/sticky-banner.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

StickyBanner.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>

Props

PropTypeDefaultDescription
idstringnoneDialog id; with remember, the localStorage key is ma-banner:<id>. Change it for a new announcement.
dismissiblebooleantrueRender the close button.
rememberbooleanfalseKeep 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.
hrefstringnoneA call-to-action link.
ctastring'Learn more'Its label.
colorstringnoneRibbon colour; default the ink token.
textColorstringnoneText colour; default the panel token.
labelstring'Announcement'Accessible name of the dialog.

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.

Why it is built this way

Replaces: Banner (Aceternity) · AnnouncementBar (Tailwind Plus) · Alert dismissible (shadcn / Radix). See the migration table.

dialog

Native, animated, zero script.

Escape, backdrop click and focus are the browser's. The scale and the blur are @starting-style and allow-discrete.

A native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser.

newly · Chrome 135 · Firefox 144 · Safari 26.2click

Notifications that slide in, stack behind each other, push up as new ones arrive, and expand into a list on hover.

newly · Chrome 117 · Firefox 129 · Safari 17.5clickhover

A sticky navigation bar that condenses over the first 120 px of scroll: the row tightens and a hairline appears, bound to the document's scroll timeline.

Chrome 115 · not Firefox · Safari 26scroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown