Component library
Back to catalog

Signal ticker bar

A 38px lime strip for the very top of a page: uppercase, tracked, heavy claims separated by "+". Static as measured; an opt-in CSS marquee pauses on hover and focus and never runs under reduced motion.

tickerstripannouncementmarqueestatic

Open preview in a new tab

Use this component

Three ways to get Signal ticker bar into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Signal ticker bar from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/signal-ticker-bar/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Signal ticker bar"
Library page: https://astro.baysixmedia.com/components/signal-ticker-bar/
Files: 1 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/SignalTickerBar.astro =====
---
/**
 * SignalTickerBar
 *
 * The thin lime strip that runs across the very top of a page: uppercase,
 * tracked, heavy items separated by "+". Static by default, as measured.
 * `marquee` opts into a CSS-only scrolling loop that pauses on hover and
 * focus and never runs under reduced motion. No script.
 *
 * Reads --nv-* tokens from any ancestor, with fallbacks.
 */

export type SignalTickerItem = string | { label: string; href?: string };

export interface SignalTickerBarProps {
	items?: SignalTickerItem[];
	/** Opt-in scrolling loop. Off by default: the reference is static. */
	marquee?: boolean;
	/** Seconds for one full loop when `marquee` is on. */
	duration?: number;
	/** Accessible name of the strip's region landmark. */
	label?: string;
	/** Strip colour. Defaults to the measured ticker lime. */
	background?: string;
	/** Text colour. */
	color?: string;
	fontStylesheet?: string;
	id?: string;
}

interface Props extends SignalTickerBarProps {}

const {
	items = [],
	marquee = false,
	duration = 38,
	label = "Service highlights",
	background,
	color,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap",
	id,
} = Astro.props;

/* The items are the caller's own claims; none are defaulted. An item with no
   label is dropped, and no items means no strip at all. */
const list = (items ?? [])
	.map((item) => (typeof item === "string" ? { label: item } : item))
	.filter((item) => item && typeof item.label === "string" && item.label.trim() !== "");

/* A marquee loop needs each group to be at least as wide as the viewport,
   or the seam shows as a gap. Estimate the group width from the copy (12px
   800 Manrope, tracked, runs about 8.6px per character) and repeat it
   inside the group until it clears a wide 1920px screen. Only the first
   copy of the first group is read out; every repeat is aria-hidden. */
const estWidth = list.reduce((w, i) => w + i.label.length * 8.6 + 34, 0) + 64;
const repeats = marquee ? Math.max(1, Math.ceil(1920 / Math.max(estWidth, 1))) : 1;

const style = [
	background ? `--stb-bg:${background}` : "",
	color ? `--stb-ink:${color}` : "",
	marquee ? `--stb-dur:${duration}s` : "",
]
	.filter(Boolean)
	.join(";");
---

{list.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{list.length > 0 && (
<section
	class:list={["stb", marquee && "stb--marquee"]}
	id={id}
	aria-label={label}
	style={style || undefined}
	data-stb-root
>
	<div class="stb-track">
		{
			Array.from({ length: marquee ? 2 : 1 }).map((_, g) => (
				<div class="stb-group" aria-hidden={g > 0 ? "true" : undefined}>
					{Array.from({ length: repeats }).map((_, r) => {
						const hidden = g > 0 || r > 0;
						return (
							<ul class="stb-list" role="list" aria-hidden={hidden && g === 0 ? "true" : undefined}>
								{list.map((item) => (
									<li class="stb-item">
										{item.href ? (
											<a class="stb-link" href={item.href} tabindex={hidden ? -1 : undefined}>
												{item.label}
											</a>
										) : (
											<span>{item.label}</span>
										)}
										<span class="stb-sep" aria-hidden="true">+</span>
									</li>
								))}
							</ul>
						);
					})}
				</div>
			))
		}
	</div>
</section>
)}

<style>
	.stb {
		--stb-bg-v: var(--stb-bg, var(--nv-lime, #b7ff39));
		--stb-ink-v: var(--stb-ink, var(--nv-ink, #08110f));

		box-sizing: border-box;
		position: relative;
		display: flex;
		align-items: center;
		width: 100%;
		height: 38px;
		overflow: hidden;
		background: var(--stb-bg-v);
		color: var(--stb-ink-v);
		font-family: var(--nv-font, Manrope, ui-sans-serif, system-ui, sans-serif);
	}

	.stb *,
	.stb *::before,
	.stb *::after {
		box-sizing: border-box;
	}

	.stb-track {
		display: flex;
		flex: none;
		align-items: center;
		width: max-content;
		padding-left: 20px;
	}

	.stb-group {
		display: flex;
		flex: none;
		align-items: center;
	}

	/* `.stb .stb-list`: beats any host `ul` reset or margin. */
	.stb .stb-list {
		display: flex;
		flex: none;
		align-items: center;
		margin: 0;
		/* The measured 64px between repeats of the message. */
		padding: 0 64px 0 0;
		list-style: none;
	}

	.stb-item {
		display: flex;
		align-items: center;
		white-space: nowrap;
		font-size: 12px;
		font-weight: 800;
		line-height: 14.4px;
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	/* Two spaces either side of the "+" in the reference: about 0.7em at
	   this size and tracking. */
	.stb-sep {
		margin: 0 0.72em 0 0.62em;
	}

	.stb-link {
		color: inherit;
		text-decoration: none;
		border-radius: 3px;
	}

	.stb-link:hover {
		text-decoration: underline;
		text-underline-offset: 2px;
	}

	.stb-link:focus-visible {
		outline: 2px solid var(--stb-ink-v);
		outline-offset: 2px;
	}

	/* ---- marquee (opt-in) ---- */
	.stb--marquee .stb-track {
		padding-left: 0;
	}

	@media (prefers-reduced-motion: no-preference) {
		.stb--marquee .stb-track {
			animation: stb-loop var(--stb-dur, 38s) linear infinite;
		}

		.stb--marquee:hover .stb-track,
		.stb--marquee:focus-within .stb-track {
			animation-play-state: paused;
		}
	}

	/* Reduced motion: the marquee version sits still, starting where the
	   static strip does. */
	@media (prefers-reduced-motion: reduce) {
		.stb--marquee .stb-track {
			padding-left: 20px;
		}
	}

	@keyframes stb-loop {
		from {
			transform: translate3d(0, 0, 0);
		}
		to {
			transform: translate3d(-50%, 0, 0);
		}
	}

	/* ---- phone ---- */
	@media (max-width: 809.98px) {
		.stb {
			height: 36px;
		}

		.stb-item {
			font-size: 11px;
			line-height: 13.2px;
		}
	}

	@media (forced-colors: active) {
		.stb {
			border-bottom: 1px solid CanvasText;
		}
	}
</style>

===== END src/components/SignalTickerBar.astro =====

===== FILE (supporting): src/components/SignalTickerBar.md =====
# Signal ticker bar

The thin lime strip across the very top of a page: short uppercase, tracked,
heavy items separated by "+". Static by default, as measured on the
reference. `marquee` turns it into a CSS-only scrolling loop.

Catalog page: `/components/signal-ticker-bar`

## Purpose

A one-line proof strip above the header — capacity, speed, a headline stat.
Use it for four or five short claims. It is not an announcement bar with a
close button and not a news feed.

## Files required

**`SignalTickerBar.astro`** — one file, the whole component. No imports, no
script, no npm packages, no images.

`SignalTickerBar.md` — this file. Documentation, not required to run.

## Basic usage

```astro
---
import SignalTickerBar from "../components/SignalTickerBar.astro";
---

<SignalTickerBar
  items={["Live carrier capacity", "Customs cleared in 4 hours", { label: "Track a load", href: "/track" }]}
/>
```

Put it first in `<body>`, above the header. It is 38px tall (36px below
810px) and sits in the flow, so an overlay header placed after it needs a
`top` offset that clears it — `GlassNavHeader` defaults to 52px, which is
38px plus the measured 14px gap.

## Props

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `(string \| { label, href? })[]` | `[]` | The items — your own claims, never defaulted. An item with `href` renders as a link; one with no label is dropped. No items → the component renders nothing. |
| `marquee` | `boolean` | `false` | Opt-in scrolling loop. The reference is static. |
| `duration` | `number` | `38` | Seconds per loop when `marquee` is on. |
| `label` | `string` | `"Service highlights"` | Accessible name of the strip's region. |
| `background` | `string` | `var(--nv-lime, #b7ff39)` | Strip colour. |
| `color` | `string` | `var(--nv-ink, #08110f)` | Text colour. |
| `fontStylesheet` | `string` | Manrope + Inter on Google Fonts | `""` skips the `<link>`. |
| `id` | `string` | — | Root id. |

## Behaviour

- **Static (default).** One run of the items, left-aligned with a 20px inset,
  clipped at the right edge on narrow screens. No transform, no animation.
- **Marquee (`marquee`).** The run is repeated inside a group until the group
  is wider than a 1920px screen (estimated from the copy length at build
  time), the group is duplicated, and the track translates `0 → -50%` on a
  linear infinite CSS animation, so the loop is seamless. It pauses on hover
  and while anything inside it has focus (`:focus-within`), and it does not
  run at all under `prefers-reduced-motion: reduce` — it then shows the same
  still strip as the static version.
- No hover state was measured on the strip. Linked items underline on hover
  and show a 2px ink focus ring.

## Accessibility

- A `<section>` with an `aria-label`, holding a real `<ul>`; the "+"
  separators are `aria-hidden`.
- In marquee mode only the first copy is exposed: every repeat and the whole
  duplicate group are `aria-hidden`, and links inside them get
  `tabindex="-1"`, so a screen reader hears the list once and Tab visits each
  link once.
- Text is ink on lime, well above 4.5:1.

## Integration notes

- Full width; paints its own background. No wrapper needed.
- If you theme with `--nv-lime`, the strip follows it. The measured ticker
  lime (`#b7ff39`) is a shade greener than the page accent (`#c8ff38`); the
  fallback keeps the measured value.
- The marquee repeat estimate assumes the default Manrope 800 at 12px. With a
  much narrower font the group can end up shorter than a very wide screen;
  add items or raise the estimate constant in the frontmatter.

## Images and credits

None. The component uses no images.

## Approximations

- The reference's static strip is offset by a leftover Framer ticker
  transform, so its first visible item is cut at the left edge. This strip
  starts cleanly at a 20px inset instead.
- The spacing around each "+" is two literal spaces in the reference; here it
  is a margin (~0.62em / 0.72em) chosen to match the screenshot's gap, not a
  measured value.
- The reference has no marquee. Its duration, pause rules and repeat logic
  are this library's.
- Tablet (810–1199px) was not captured; it uses the desktop size.

===== END src/components/SignalTickerBar.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.

--- NOT PROVEN ---
- ClientRouter: not applicable — this component ships no script.

--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.

--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Signal ticker bar” from my Astro Component Library:
https://astro.baysixmedia.com/components/signal-ticker-bar/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Static
Profile
core only
Installation files
1 required + 1 supporting
Verified
Never
Client navigation
Not applicable — ships no script
Source fingerprint
No fingerprint recorded
Production ready
Not yet

Not yet verified

Enrolled for verification, but never run. Silence is reported as silence rather than assumed to be fine.

Open the verification fixture

src/components/library/SignalTickerBar.astro

one fileNo imports, no companion files, no packages. Copy SignalTickerBar.astro into any Astro project's components folder and render it.

---
/**
 * SignalTickerBar
 *
 * The thin lime strip that runs across the very top of a page: uppercase,
 * tracked, heavy items separated by "+". Static by default, as measured.
 * `marquee` opts into a CSS-only scrolling loop that pauses on hover and
 * focus and never runs under reduced motion. No script.
 *
 * Reads --nv-* tokens from any ancestor, with fallbacks.
 */

export type SignalTickerItem = string | { label: string; href?: string };

export interface SignalTickerBarProps {
	items?: SignalTickerItem[];
	/** Opt-in scrolling loop. Off by default: the reference is static. */
	marquee?: boolean;
	/** Seconds for one full loop when `marquee` is on. */
	duration?: number;
	/** Accessible name of the strip's region landmark. */
	label?: string;
	/** Strip colour. Defaults to the measured ticker lime. */
	background?: string;
	/** Text colour. */
	color?: string;
	fontStylesheet?: string;
	id?: string;
}

interface Props extends SignalTickerBarProps {}

const {
	items = [],
	marquee = false,
	duration = 38,
	label = "Service highlights",
	background,
	color,
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap",
	id,
} = Astro.props;

/* The items are the caller's own claims; none are defaulted. An item with no
   label is dropped, and no items means no strip at all. */
const list = (items ?? [])
	.map((item) => (typeof item === "string" ? { label: item } : item))
	.filter((item) => item && typeof item.label === "string" && item.label.trim() !== "");

/* A marquee loop needs each group to be at least as wide as the viewport,
   or the seam shows as a gap. Estimate the group width from the copy (12px
   800 Manrope, tracked, runs about 8.6px per character) and repeat it
   inside the group until it clears a wide 1920px screen. Only the first
   copy of the first group is read out; every repeat is aria-hidden. */
const estWidth = list.reduce((w, i) => w + i.label.length * 8.6 + 34, 0) + 64;
const repeats = marquee ? Math.max(1, Math.ceil(1920 / Math.max(estWidth, 1))) : 1;

const style = [
	background ? `--stb-bg:${background}` : "",
	color ? `--stb-ink:${color}` : "",
	marquee ? `--stb-dur:${duration}s` : "",
]
	.filter(Boolean)
	.join(";");
---

{list.length > 0 && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{list.length > 0 && (
<section
	class:list={["stb", marquee && "stb--marquee"]}
	id={id}
	aria-label={label}
	style={style || undefined}
	data-stb-root
>
	<div class="stb-track">
		{
			Array.from({ length: marquee ? 2 : 1 }).map((_, g) => (
				<div class="stb-group" aria-hidden={g > 0 ? "true" : undefined}>
					{Array.from({ length: repeats }).map((_, r) => {
						const hidden = g > 0 || r > 0;
						return (
							<ul class="stb-list" role="list" aria-hidden={hidden && g === 0 ? "true" : undefined}>
								{list.map((item) => (
									<li class="stb-item">
										{item.href ? (
											<a class="stb-link" href={item.href} tabindex={hidden ? -1 : undefined}>
												{item.label}
											</a>
										) : (
											<span>{item.label}</span>
										)}
										<span class="stb-sep" aria-hidden="true">+</span>
									</li>
								))}
							</ul>
						);
					})}
				</div>
			))
		}
	</div>
</section>
)}

<style>
	.stb {
		--stb-bg-v: var(--stb-bg, var(--nv-lime, #b7ff39));
		--stb-ink-v: var(--stb-ink, var(--nv-ink, #08110f));

		box-sizing: border-box;
		position: relative;
		display: flex;
		align-items: center;
		width: 100%;
		height: 38px;
		overflow: hidden;
		background: var(--stb-bg-v);
		color: var(--stb-ink-v);
		font-family: var(--nv-font, Manrope, ui-sans-serif, system-ui, sans-serif);
	}

	.stb *,
	.stb *::before,
	.stb *::after {
		box-sizing: border-box;
	}

	.stb-track {
		display: flex;
		flex: none;
		align-items: center;
		width: max-content;
		padding-left: 20px;
	}

	.stb-group {
		display: flex;
		flex: none;
		align-items: center;
	}

	/* `.stb .stb-list`: beats any host `ul` reset or margin. */
	.stb .stb-list {
		display: flex;
		flex: none;
		align-items: center;
		margin: 0;
		/* The measured 64px between repeats of the message. */
		padding: 0 64px 0 0;
		list-style: none;
	}

	.stb-item {
		display: flex;
		align-items: center;
		white-space: nowrap;
		font-size: 12px;
		font-weight: 800;
		line-height: 14.4px;
		letter-spacing: 0.08em;
		text-transform: uppercase;
	}

	/* Two spaces either side of the "+" in the reference: about 0.7em at
	   this size and tracking. */
	.stb-sep {
		margin: 0 0.72em 0 0.62em;
	}

	.stb-link {
		color: inherit;
		text-decoration: none;
		border-radius: 3px;
	}

	.stb-link:hover {
		text-decoration: underline;
		text-underline-offset: 2px;
	}

	.stb-link:focus-visible {
		outline: 2px solid var(--stb-ink-v);
		outline-offset: 2px;
	}

	/* ---- marquee (opt-in) ---- */
	.stb--marquee .stb-track {
		padding-left: 0;
	}

	@media (prefers-reduced-motion: no-preference) {
		.stb--marquee .stb-track {
			animation: stb-loop var(--stb-dur, 38s) linear infinite;
		}

		.stb--marquee:hover .stb-track,
		.stb--marquee:focus-within .stb-track {
			animation-play-state: paused;
		}
	}

	/* Reduced motion: the marquee version sits still, starting where the
	   static strip does. */
	@media (prefers-reduced-motion: reduce) {
		.stb--marquee .stb-track {
			padding-left: 20px;
		}
	}

	@keyframes stb-loop {
		from {
			transform: translate3d(0, 0, 0);
		}
		to {
			transform: translate3d(-50%, 0, 0);
		}
	}

	/* ---- phone ---- */
	@media (max-width: 809.98px) {
		.stb {
			height: 36px;
		}

		.stb-item {
			font-size: 11px;
			line-height: 13.2px;
		}
	}

	@media (forced-colors: active) {
		.stb {
			border-bottom: 1px solid CanvasText;
		}
	}
</style>