diff --git a/.specs/ticker.md b/.specs/ticker.md new file mode 100644 index 000000000..aa84418a7 --- /dev/null +++ b/.specs/ticker.md @@ -0,0 +1,154 @@ +--- +name: ticker +category: marketing +structure: monolithic +status: approved +spec_version: 1 +checksum: bbd1646868aee1bf69ce86f3b81595ee8b491320cf9c4acfc3872253fd664e73 +created: 2026-09-23 +last_updated: 2026-10-01 +--- + +# Ticker — Component Spec + +## Purpose + +An endlessly looping row of brand marks — the trust strip that names the companies running on the platform, and the stack strip that names the frameworks and clouds a workload already uses. The marks come from the package's own registry and are drawn in one ink, so a strip reads as a list rather than as a dozen competing palettes. + +## When to use + +- Under a hero, to name the companies a product already serves ("Trusted by mission-critical workloads"). +- To state compatibility as a list of framework, cloud and tooling marks ("Your Stack, Your Way"). +- Whenever the set of marks is longer than the row that holds it and no single mark has to be read. + +## When NOT to use + +- When every mark must be seen → use `logo-wall`, which wraps into a grid and never moves. +- When the reader chooses how far to go through browsable content → use `carousel`, which is user-driven and never advances on its own. +- For a mark a reader must act on, such as a partner directory → the strip is decorative motion, so put linked marks in a `logo-wall`. +- For one mark in its own right (a header lockup, a client story card) → use `brand`. + +## Related + +- `logo-wall` — the static, wrapping grid of consumer-supplied marks; reach for the strip only when the wall would be too long. +- `carousel` — the user-driven snap scroller; this one moves on its own and holds no essential content. +- `brand` — the Azion lockup, for the product's own mark rather than a third party's. +- `overline` — renders the `label` above the row. + +## Best practices + +- Name marks from the registry rather than passing artwork: the registry is what keeps one company's mark identical on every surface, and a name with no entry still renders as its own wordmark so a strip is never incomplete. +- Let `duration` derive itself. The component holds speed constant rather than duration, so an eleven-mark strip and a thirty-mark strip advance at the same rate; pass a number only to slow one deliberately. +- Keep `label` to the claim the row makes, not to a heading — the strip sits under a hero, and a second heading competes with the one above it. +- Use `size="small"` only where the strip is the quietest thing on the page, such as the floor of a sign-in column; `medium` is the marketing band and carries its own responsive ladder. +- Never put a call to action, a price or anything else a reader needs inside a strip. It scrolls away, and under `prefers-reduced-motion` it becomes a wrapped grid instead. + +## Usage + +```vue + + + +``` + +## Props + +| Prop | Type | Default | Required | JSDoc | +| ----------- | --------------------- | ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `kind` | `'plain' \| 'band'` | `'plain'` | false | Whether the strip paints its own ground; `band` fills it with the page canvas so a textured hero cannot show through the marks, and the floor still reads continuous with the band above it. | +| `marks` | `string[]` | `[]` | false | Registry names of the marks rendered in the row, in order; an unregistered name renders as its own typographic wordmark so the row stays complete. | +| `label` | `string` | `''` | false | Overline above the row, stating the claim the marks make. | +| `size` | `'small' \| 'medium'` | `'medium'` | false | Mark scale; `medium` is the marketing band and climbs 32/40/48 px by device class, `small` is a flat 24 px for a column. | +| `duration` | `number` | `0` | false | Seconds for one full pass; left at `0` it is derived from the number of marks so every strip moves at one speed. | +| `ariaLabel` | `string` | `''` | false | Accessible name for the row of marks, announced instead of an unnamed list. | + +## Events + +| _none_ | — | — | + +## Slots + +| _none_ | — | — | + +## States + +- Visual states: every mark rests at 70% opacity and lifts to full on `hover` +- `data-size` mirrors the `size` prop and is what drives the mark height, the cell width and the row gap +- `data-kind` mirrors the `kind` prop; `band` paints the page canvas and the strip's own vertical rhythm so nothing behind it shows through while the fill still matches the band it floors, `plain` leaves it transparent over whatever it sits on +- `data-duplicate` marks every `aria-hidden` copy after the first — the copies the loop translates into place, and the ones hidden outright under reduced motion +- Short rows: the row repeats until half the track is at least as wide as the strip, so a strip with few marks never shows an empty run; the pass lengthens with each repeat so the speed stays the same +- Empty: when `marks` is empty the row renders nothing, so a page with no marks to name shows no empty band +- Reduced motion: the track stops and wraps into a centred static grid, with the duplicate rows removed, so every mark stays reachable + +## Motion & Animations + +| Trigger | Animation / Transition | Token (see `.claude/docs/DESIGN.md` § Animations) | Reduced-motion fallback | +| ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | +| always (the loop) | `animate-brand-marquee` | semantic (59s default · linear infinite; the track overrides `animation-duration` from its mark count) | `motion-reduce:animate-none` (the row wraps into a static grid) | +| hover on a mark | `transition-opacity duration-fast-02 ease-productive-entrance` | inline (matches catalog) | `motion-reduce:transition-none` | + +## Tokens + +| Region | Token (DESIGN.md) | +| --------------------------------- | --------------------------------------------------- | +| typography (fallback wordmark) | `.text-heading-xxs` | +| ink (mark) | `var(--text-default)` at 70% opacity, full on hover | +| spacing (label ↔ row) | `var(--spacing-xl)` | +| spacing (row gap, medium) | `var(--spacing-4)` / `var(--spacing-8)` | +| spacing (row gap, small) | `var(--spacing-10)`, cells sized to the mark | +| spacing (reduced-motion grid gap) | `var(--spacing-md)` | + +## Theme gaps + +| Figma variable | Temporary primitive | Follow-up | +| -------------- | ------------------- | --------- | +| _none_ | — | — | + +## Accessibility (WCAG 2.1 AA) + +- Visible focus: none of its own — no mark is a focusable target, so the strip adds no tab stop and needs no ring. The loop still pauses on `:focus-within`, so anything a consumer places over it stays still while it is reached. +- Keyboard map: no arrow-key model and no tab stop — this is a list, not a composite widget. +- ARIA: the row is a `