Marquee
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add media/marqueePlain-CSS theme — no build step
npx astrocraft-ui add media/marquee --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add media/marquee --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add media/marquee --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add media/marquee --theme tailwind --bridge lumosLive demo
Marquee
Pure CSS; hover to pause, reduced-motion pauses it entirely.
Marquee pause
WCAG 2.2.2: content that moves on its own for more than five seconds needs a way to stop it, and pause-on-hover is a pointer affordance that a keyboard user cannot reach. The button sits outside the Marquee — its slot is rendered once per track copy — and is wired to it by id. The state is aria-pressed on the button and data-paused on the marquee, which structure.css turns into animation-play-state: paused.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `Marquee.astro` | `marquee` `marquee-track` | `data-direction`: `left` · `right` · `up` · `down` `data-speed`: `slow` · `normal` · `fast` `data-pause-on-hover`: `true` when set | — | — |
| `MarqueeControls.astro` | `marquee-controls` | — | `data-paused` | `[aria-pressed]` |
Source
What the command copies — 3 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/media/marquee/Marquee.astro — headless primitive (see ../../README.md).
// Seamless infinite scroll. What this primitive owns is the STRUCTURE the illusion needs: the slot
// is rendered `repeat` times as identical side-by-side tracks, and copies after the first are
// aria-hidden (they are visual duplicates). The keyframe, the gap, and the duration are the theme's
// — it reads `data-direction` (which axis, and whether to run in reverse) and `data-speed`, and must
// pair `motion-reduce` with any animation it adds (non-negotiable a11y — see ../../README.md).
//
// ponytail: `repeat` copies fill the viewport; if the content is much narrower than the viewport,
// raise it (each copy is one more render of the slot). At least 2 are needed for the loop to close.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
direction?: "left" | "right" | "up" | "down";
speed?: "slow" | "normal" | "fast";
pauseOnHover?: boolean;
repeat?: number;
};
const {
direction = "left",
speed = "normal",
pauseOnHover = true,
repeat = 4,
class: className,
...rest
} = Astro.props;
const copies = Math.max(2, repeat);
---
<div
class={className}
data-slot="marquee"
data-direction={direction}
data-speed={speed}
data-pause-on-hover={pauseOnHover ? "true" : undefined}
{...rest}
>
{
Array.from({ length: copies }).map((_, i) => (
<div data-slot="marquee-track" aria-hidden={i > 0 ? "true" : undefined}>
<slot />
</div>
))
}
</div>
---
// src/components/ui/media/marquee/MarqueeControls.astro — Marquee compound part (see ../../README.md).
// The pause button a Marquee is not allowed to ship without.
//
// WCAG 2.2.2 (Pause, Stop, Hide) is not a suggestion: any content that moves automatically for more
// than five seconds needs a mechanism to pause it, and a Marquee moves forever. Until this part
// existed the only way to stop one was `pause-on-hover`, which is a POINTER affordance — useless to
// a keyboard user, useless on a touch screen, and useless to the people most affected, who get
// motion sickness or cannot read past moving text. That is a gap in a shipped primitive, which is
// why this landed as a fix rather than a feature.
//
// It sits OUTSIDE the Marquee, and has to: Marquee renders its slot once per track copy, so a
// control placed inside it would be duplicated `repeat` times, each copy but the first inside an
// `aria-hidden` subtree. So it is wired by id instead —
//
// <Marquee id="logos">…</Marquee>
// <MarqueeControls for="logos" />
//
// The state is `aria-pressed` on the button (already carried by the platform — see contract rule 4)
// and `data-paused` on the marquee, which `structure.css` turns into `animation-play-state: paused`.
// The accessible NAME stays "Pause animation" in both states: a toggle button that renames itself
// reads as a different control every time it is pressed, and the pressed state is what carries "it
// is paused now". That is the APG toggle pattern, and it is the one PasswordInput already follows.
import "../../../../styles/structure.css";
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"button"> & {
/** The `id` of the Marquee this pauses. Required — the control is useless pointed at nothing. */
for: string;
/** Start paused. Useful when your own code has decided motion is unwelcome on this page. */
paused?: boolean;
label?: string;
};
const {
for: controls,
paused = false,
label = "Pause animation",
class: className,
...rest
} = Astro.props;
---
<button
type="button"
aria-controls={controls}
aria-pressed={paused ? "true" : "false"}
aria-label={Astro.slots.has("default") ? undefined : label}
class={className}
data-slot="marquee-controls"
{...rest}
>
<slot />
</button>
<script>
import { onReadyOnce } from "../../_once";
function wire(button: HTMLElement) {
const id = button.getAttribute("aria-controls");
const marquee = id ? document.getElementById(id) : null;
// A control that points at nothing would sit there claiming to pause something. Better it does
// not render as operable at all than lie about it.
if (!marquee) {
button.setAttribute("disabled", "");
return;
}
const apply = (paused: boolean) => {
button.setAttribute("aria-pressed", String(paused));
marquee.dataset.paused = String(paused);
};
// Server-rendered `paused` is on the BUTTON; the marquee learns about it here, so the two agree
// from the first frame rather than from the first click.
apply(button.getAttribute("aria-pressed") === "true");
button.addEventListener("click", () => {
apply(button.getAttribute("aria-pressed") !== "true");
});
}
onReadyOnce('[data-slot="marquee-controls"]', wire);
</script>
import Marquee from "./Marquee.astro";
import MarqueeControls from "./MarqueeControls.astro";
export { Marquee, MarqueeControls };
export default Marquee;
What you get
The component source, copied into your project by npx astrocraft-ui add media/marquee — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.