Northwind rebrand
A new wordmark, palette and signage system for a grocer of forty stores, rolled out without closing a single one.
<bmx-carousel>A strip of slides the reader moves through.
12 properties · 2 events · 5 methods · 8 parts
A new wordmark, palette and signage system for a grocer of forty stores, rolled out without closing a single one.
Five years of results told in charts a shareholder can read at a glance, in print and as an accessible web edition.
An app, a waitlist and a launch-day site that took 120,000 sign-ups before the first download.
inert.
scroll-snap container, not a transform. So the flick physics on a phone are the ones that
phone already has — momentum, the rubber-band at the ends, and what happens when a drag is interrupted —
and none of it is imitated here. The cost is that the component cannot know where the strip is and has to ask:
every decision it makes comes from a measured scroll offset rather than from an index it keeps.
per-view is the only sizing property; the slides take an equal share of the viewport, so the layout
is one number rather than a media query per breakpoint. Set it from your own breakpoints when you want a different
count on a phone.
prefers-reduced-motion, and no property overrides that:
it is a statement about what makes somebody unwell rather than a preference about animation. It also stops while a
pointer is over the carousel, while the focus is inside it, while the tab is in the background, and whenever the
reader presses pause — and each of those is reported by name on bmxAutoplayChange, so your page
can say why it is still. While the clock is running the live region is off; the moment the
reader takes control it becomes polite.
<bmx-carousel class="ex-work" label="Featured work" per-view="1" slide-labels="Northwind rebrand,Contoso annual report,Fabrikam launch">
<article class="ex-slide">
<svg class="ex-art" viewBox="0 0 400 240" role="img" aria-label="Northwind's new wordmark on a shopping bag, with the brand palette">
<defs>
<linearGradient id="ex-nw-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3b5bdb" />
<stop offset="1" stop-color="#0b7285" />
</linearGradient>
</defs>
<rect width="400" height="240" fill="url(#ex-nw-bg)" />
<circle cx="350" cy="30" r="90" fill="#ffffff" opacity="0.07" />
<circle cx="40" cy="230" r="70" fill="#ffffff" opacity="0.06" />
<path d="M150 74h100l12 132H138z" fill="#f8f9fa" />
<path d="M172 74c0-26 56-26 56 0" fill="none" stroke="#f8f9fa" stroke-width="7" stroke-linecap="round" />
<path d="M200 108l14 26-14 26-14-26z" fill="#3b5bdb" />
<path d="M200 108l14 26h-28z" fill="#0b7285" />
<text x="200" y="186" text-anchor="middle" font-size="15" font-weight="800" letter-spacing="3" fill="#1c2b4a">NORTHWIND</text>
<g transform="translate(28 34)">
<rect width="78" height="96" rx="10" fill="#ffffff" opacity="0.14" />
<rect x="12" y="12" width="24" height="24" rx="6" fill="#ffd43b" />
<rect x="42" y="12" width="24" height="24" rx="6" fill="#f8f9fa" />
<rect x="12" y="42" width="24" height="24" rx="6" fill="#1c2b4a" />
<rect x="42" y="42" width="24" height="24" rx="6" fill="#63e6be" />
<rect x="12" y="74" width="54" height="8" rx="4" fill="#ffffff" opacity="0.55" />
</g>
<rect x="296" y="160" width="76" height="26" rx="13" fill="#ffd43b" />
<text x="334" y="177" text-anchor="middle" font-size="11" font-weight="700" fill="#1c2b4a">40 stores</text>
</svg>
<div class="ex-copy">
<span class="ex-kicker">Brand identity · 12 weeks</span>
<h4 class="ex-title">Northwind rebrand</h4>
<p class="ex-desc">A new wordmark, palette and signage system for a grocer of forty stores, rolled out without closing a single one.</p>
<bmx-button size="sm" variant="soft">View the case study</bmx-button>
</div>
</article>
<article class="ex-slide">
<svg class="ex-art" viewBox="0 0 400 240" role="img" aria-label="Contoso's annual report open on a chart of five years' growth">
<defs>
<linearGradient id="ex-co-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#2b8a3e" />
<stop offset="1" stop-color="#0c6b58" />
</linearGradient>
</defs>
<rect width="400" height="240" fill="url(#ex-co-bg)" />
<circle cx="360" cy="210" r="96" fill="#ffffff" opacity="0.07" />
<g transform="rotate(-6 120 130)">
<rect x="52" y="44" width="136" height="172" rx="8" fill="#f8f9fa" />
<rect x="68" y="62" width="64" height="9" rx="4" fill="#2b8a3e" />
<text x="68" y="98" font-size="22" font-weight="800" fill="#12301c">2026</text>
<text x="68" y="114" font-size="9" font-weight="600" fill="#5c7a64" letter-spacing="1">ANNUAL REPORT</text>
<rect x="68" y="134" width="104" height="5" rx="2.5" fill="#ced4da" />
<rect x="68" y="146" width="88" height="5" rx="2.5" fill="#ced4da" />
<rect x="68" y="158" width="96" height="5" rx="2.5" fill="#ced4da" />
<rect x="68" y="182" width="40" height="18" rx="4" fill="#b2f2bb" />
<rect x="114" y="182" width="40" height="18" rx="4" fill="#d3f9d8" />
</g>
<g transform="translate(214 52)">
<rect width="160" height="136" rx="12" fill="#ffffff" opacity="0.14" />
<rect x="18" y="86" width="18" height="32" rx="3" fill="#d3f9d8" />
<rect x="46" y="70" width="18" height="48" rx="3" fill="#b2f2bb" />
<rect x="74" y="56" width="18" height="62" rx="3" fill="#8ce99a" />
<rect x="102" y="40" width="18" height="78" rx="3" fill="#69db7c" />
<rect x="130" y="22" width="18" height="96" rx="3" fill="#ffd43b" />
<path d="M27 80 55 64 83 50 111 34 139 16" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="139" cy="16" r="5" fill="#ffffff" />
</g>
<rect x="250" y="200" width="104" height="24" rx="12" fill="#ffd43b" />
<text x="302" y="216" text-anchor="middle" font-size="11" font-weight="700" fill="#12301c">+38% in 5 years</text>
</svg>
<div class="ex-copy">
<span class="ex-kicker">Editorial design · 64 pages</span>
<h4 class="ex-title">Contoso annual report</h4>
<p class="ex-desc">Five years of results told in charts a shareholder can read at a glance, in print and as an accessible web edition.</p>
<bmx-button size="sm" variant="soft">View the case study</bmx-button>
</div>
</article>
<article class="ex-slide">
<svg class="ex-art" viewBox="0 0 400 240" role="img" aria-label="The Fabrikam app on a phone, with a launch-day countdown">
<defs>
<linearGradient id="ex-fa-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#a9450b" />
<stop offset="1" stop-color="#862e9c" />
</linearGradient>
</defs>
<rect width="400" height="240" fill="url(#ex-fa-bg)" />
<circle cx="60" cy="40" r="80" fill="#ffffff" opacity="0.07" />
<path d="M300 40l4 10 10 4-10 4-4 10-4-10-10-4 10-4z" fill="#ffd43b" />
<path d="M352 118l3 7 7 3-7 3-3 7-3-7-7-3 7-3z" fill="#ffffff" opacity="0.8" />
<path d="M60 170l3 7 7 3-7 3-3 7-3-7-7-3 7-3z" fill="#ffffff" opacity="0.7" />
<rect x="150" y="22" width="100" height="200" rx="18" fill="#1a1029" />
<rect x="157" y="30" width="86" height="184" rx="12" fill="#f8f0fc" />
<rect x="187" y="36" width="26" height="6" rx="3" fill="#1a1029" />
<rect x="166" y="54" width="68" height="56" rx="10" fill="#862e9c" />
<path d="M186 96l14-26 14 26z" fill="#ffd43b" />
<circle cx="200" cy="82" r="5" fill="#f8f0fc" />
<rect x="166" y="120" width="52" height="7" rx="3.5" fill="#5f3dc4" />
<rect x="166" y="133" width="68" height="5" rx="2.5" fill="#d0bfff" />
<rect x="166" y="143" width="60" height="5" rx="2.5" fill="#d0bfff" />
<rect x="166" y="184" width="68" height="20" rx="10" fill="#a9450b" />
<text x="200" y="198" text-anchor="middle" font-size="9" font-weight="700" fill="#ffffff">Get the app</text>
<g transform="translate(270 150)">
<rect width="104" height="58" rx="12" fill="#ffffff" opacity="0.16" />
<text x="52" y="26" text-anchor="middle" font-size="20" font-weight="800" fill="#ffffff">03:12:40</text>
<text x="52" y="44" text-anchor="middle" font-size="9" font-weight="600" fill="#ffffff" opacity="0.85" letter-spacing="1">TO LAUNCH</text>
</g>
<g transform="translate(26 74)">
<rect width="104" height="58" rx="12" fill="#ffffff" opacity="0.16" />
<text x="52" y="26" text-anchor="middle" font-size="20" font-weight="800" fill="#ffffff">120k</text>
<text x="52" y="44" text-anchor="middle" font-size="9" font-weight="600" fill="#ffffff" opacity="0.85" letter-spacing="1">ON THE WAITLIST</text>
</g>
</svg>
<div class="ex-copy">
<span class="ex-kicker">Product launch · iOS and Android</span>
<h4 class="ex-title">Fabrikam launch</h4>
<p class="ex-desc">An app, a waitlist and a launch-day site that took 120,000 sign-ups before the first download.</p>
<bmx-button size="sm" variant="soft">View the case study</bmx-button>
</div>
</article>
</bmx-carousel>
<style>
.ex-work { inline-size: 100%; }
.ex-slide {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.25rem 1.5rem;
margin: 0;
padding: 1rem;
border: 1px solid var(--bmx-border);
border-radius: 0.875rem;
background: var(--bmx-surface-raised, var(--bmx-surface));
}
.ex-art { flex: 1.3 1 16rem; min-inline-size: 0; block-size: auto; aspect-ratio: 5 / 3; border-radius: 0.625rem; font-family: inherit; }
.ex-copy { flex: 1 1 14rem; display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; min-inline-size: 0; }
.ex-kicker { color: var(--bmx-primary-soft-text); font-size: 0.75rem; font-weight: 650; letter-spacing: 0.06em; text-transform: uppercase; }
.content .ex-title { margin: 0; color: var(--bmx-text); font-size: 1.25rem; font-weight: 700; letter-spacing: -0.01em; }
.content .ex-desc { margin: 0 0 0.5rem; color: var(--bmx-text-muted); font-size: 0.9375rem; line-height: 1.55; }
</style>
<div class="row" style="margin-block-start: 1.5rem">
<span class="note">
<strong>Try it without the mouse.</strong> Tab into the strip once, then use the arrows —
<kbd>Home</kbd> and <kbd>End</kbd> jump to the ends. Then Tab again: the focus goes to the dots and
<em>not</em> through the slides you cannot see. A carousel of six slides each holding a link normally costs a
keyboard user six invisible tab stops; everything off screen here is <code>inert</code>.
</span>
</div>
<div class="row" style="margin-block-start: 1rem">
<span class="note">
It is a <code>scroll-snap</code> container, not a transform. So the flick physics on a phone are the ones that
phone already has — momentum, the rubber-band at the ends, and what happens when a drag is interrupted —
and none of it is imitated here. The cost is that the component cannot know where the strip is and has to ask:
every decision it makes comes from a measured scroll offset rather than from an index it keeps.
</span>
</div>
<div class="row" style="margin-block-start: 1.5rem">
<bmx-carousel class="ex-work" label="Recently viewed" per-view="3" hide-indicator>
<div class="ex-card">
<span class="ex-thumb" style="--ex-hue: #3b5bdb"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 2h6l3 8H6z" /><path d="M12 10v8" /><path d="M8 22h8" /></svg></span>
<span class="ex-name">Desk lamp</span>
<span class="ex-meta">Lighting · £49</span>
</div>
<div class="ex-card">
<span class="ex-thumb" style="--ex-hue: #0c8599"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 14h3a3 3 0 0 1 3 3v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-2a3 3 0 0 1 3-3h3" /></svg></span>
<span class="ex-name">Headphones</span>
<span class="ex-meta">Audio · £129</span>
</div>
<div class="ex-card">
<span class="ex-thumb" style="--ex-hue: #2b8a3e"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 14h10l-1.5 8h-7z" /><path d="M12 14V9" /><path d="M12 9c0-3 2-5 5-5 0 3-2 5-5 5z" /><path d="M12 11c0-2.5-1.5-4-4-4 0 2.5 1.5 4 4 4z" /></svg></span>
<span class="ex-name">Plant pot</span>
<span class="ex-meta">Home · £22</span>
</div>
<div class="ex-card">
<span class="ex-thumb" style="--ex-hue: #e8590c"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg></span>
<span class="ex-name">Wall clock</span>
<span class="ex-meta">Home · £35</span>
</div>
<div class="ex-card">
<span class="ex-thumb" style="--ex-hue: #862e9c"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h12a2 2 0 0 1 2 2v14H6a2 2 0 0 1-2-2z" /><path d="M8 8h6" /><path d="M8 12h6" /><path d="M18 8h2v12h-2" /></svg></span>
<span class="ex-name">Notebook set</span>
<span class="ex-meta">Stationery · £18</span>
</div>
<div class="ex-card">
<span class="ex-thumb" style="--ex-hue: #c2255c"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 8h11v8a4 4 0 0 1-4 4H9a4 4 0 0 1-4-4z" /><path d="M16 10h1.5a2.5 2.5 0 0 1 0 5H16" /><path d="M9 2v3" /><path d="M12 2v3" /></svg></span>
<span class="ex-name">Stoneware mug</span>
<span class="ex-meta">Kitchen · £12</span>
</div>
</bmx-carousel>
</div>
<style>
.ex-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.875rem;
border: 1px solid var(--bmx-border);
border-radius: 0.75rem;
background: var(--bmx-surface-raised, var(--bmx-surface));
}
.ex-thumb {
display: grid;
place-items: center;
inline-size: 100%;
block-size: 4.5rem;
margin-block-end: 0.375rem;
border-radius: 0.5rem;
background: linear-gradient(135deg, color-mix(in oklab, var(--ex-hue) 22%, transparent), color-mix(in oklab, var(--ex-hue) 8%, transparent));
color: color-mix(in oklab, var(--ex-hue) 70%, var(--bmx-text));
}
.ex-thumb svg { inline-size: 2rem; block-size: 2rem; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
.ex-name { color: var(--bmx-text); font-weight: 650; }
.ex-meta { color: var(--bmx-text-muted); font-size: 0.8125rem; }
</style>
<div class="row" style="margin-block-start: 1rem">
<span class="note">
<code>per-view</code> is the only sizing property; the slides take an equal share of the viewport, so the layout
is one number rather than a media query per breakpoint. Set it from your own breakpoints when you want a different
count on a phone.
</span>
</div>
<div class="row" style="margin-block-start: 1rem">
<span class="note">
<strong>Autoplay never runs under <code>prefers-reduced-motion</code></strong>, and no property overrides that:
it is a statement about what makes somebody unwell rather than a preference about animation. It also stops while a
pointer is over the carousel, while the focus is inside it, while the tab is in the background, and whenever the
reader presses pause — and each of those is reported by name on <code>bmxAutoplayChange</code>, so your page
can say <em>why</em> it is still. While the clock is running the live region is <code>off</code>; the moment the
reader takes control it becomes <code>polite</code>.
</span>
</div>
<bmx-carousel label="Featured work">
<figure>…</figure>
<figure>…</figure>
</bmx-carousel>
The usual carousel owns a strip and moves it with a transform, and then has
to reimplement everything the reader's device already does: momentum,
rubber-banding at the ends, the feel of a flick, and - the one that is never
quite right - what happens when a drag is interrupted. This one is a
scroll-snap container with the slides slotted into it, so all of that is
the browser's, natively, on the hardware it is running on.
The cost is that the component does not know where the strip is; it has to
ask. Every decision is therefore made from a measured scroll offset rather
than from an index this component keeps - see src/core/carousel.ts. A
component that kept its own index beside a scroll container would have two
sources of truth, and they disagree the first time somebody drags.
The host is a group with aria-roledescription="carousel" and the dots are
an indicator: they say where you are and let you jump, and they claim
nothing more. That is what almost every carousel is, and it is what the WAI
pattern describes.
tabs turns the picker into a real tablist, for the case where each slide is
a named destination the reader is choosing between rather than a position in
a sequence. It is opt-in because the wrong one of these is a lie to a screen
reader: announcing a set of photographs as tabs tells somebody they are
expected to choose, when the honest answer is that there are six pictures and
they are welcome to look at them.
In tabs mode the tab and its slide cannot be joined by an id. The tabs
are drawn in this shadow root and the slides are the author's own light-DOM
children, and an ID reference does not cross a node tree - the same
constraint that shaped bmx-tabs. The association is carried by element
reflection where the browser has it and left off where it does not, which
loses a relationship and no content. The slide's own name is set directly,
so it is never the thing that goes missing.
Under prefers-reduced-motion it does not run, and no property overrides
that. It also stops while a pointer is over the carousel, while the focus is
inside it, while the tab is in the background, and whenever the reader presses
pause - each reported by name, so the component can say why it is still.
And while the clock is running the live region is off: a carousel that
announces a slide every five seconds interrupts the rest of the page forever,
for something nobody asked to happen. The moment the reader takes control it
becomes polite. The policy is autoplayState and announcement, both pure.
Which is the defect this pattern ships with almost everywhere: a carousel of
six slides, each holding a link, costs a keyboard user six invisible tab
stops. Everything not on screen is inert.
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
autoplay |
autoplay |
boolean |
false |
Move on its own. Never under prefers-reduced-motion - see above. |
hideControls |
hide-controls |
boolean |
false |
Hide the previous and next controls, when the page provides its own. |
hideIndicator |
hide-indicator |
boolean |
false |
Hide the dots or tablist. |
index |
index |
number |
0 |
The slide showing now, counting from 0. Assignable. |
interval |
interval |
number |
5000 |
How long each slide is shown, in milliseconds. |
label |
label |
string |
— | What this carousel is. A carousel with no name is announced as "carousel" and nothing else, which tells a reader that something is here and not what. |
loop |
loop |
boolean |
false |
Whether the end wraps round to the beginning. Off by default: a carousel that silently returns to the first slide is one a reader cannot tell they have finished, and "have I seen all of these?" is the question this pattern is worst at answering. |
orientation |
orientation |
BmxOrientation |
'horizontal' |
Which way it travels. |
perView |
per-view |
number |
1 |
How many slides are on screen at once. |
slideLabels |
slide-labels |
string[] | string |
[] |
A name for each slide. Used for the tab labels in tabs mode and for each slide's own accessible name in either. Without them a slide is announced as "3 of 12", which is a position rather than a description - true, and less than the author knows. Read through src/core/markup.ts, so a comma-separated or JSON attribute from a server-rendered template is the list it looks like. |
step |
step |
number |
— | How many slides one press of previous or next moves. Defaults to a full view. |
tabs |
tabs |
boolean |
false |
Draw the picker as a real tablist rather than as an indicator. Only when each slide genuinely is a named destination. See the note above: the wrong one of these is a lie to a screen reader. |
| Event | Detail | Description |
|---|---|---|
bmxAutoplayChange |
BmxCarouselAutoplayDetail |
The clock started or stopped, and why. |
bmxChange |
BmxCarouselChangeDetail |
The slide changed. |
| Method | Signature | Description |
|---|---|---|
goTo |
goTo(index: number) => Promise<void> |
Go to a slide by its index. |
next |
next() => Promise<void> |
Move to the next slide. |
pause |
pause() => Promise<void> |
Stop the clock until play() or the pause control says otherwise. |
play |
play() => Promise<void> |
Start the clock, if anything else will let it run. |
previous |
previous() => Promise<void> |
Move to the previous slide. |
| Slot | Description |
|---|---|
(default) |
The slides. Anything at all; this component has no opinion about them. |
| Part | Description |
|---|---|
dot |
One dot. |
frame |
|
indicator |
The dots, or the tablist in tabs mode. |
next |
The control that steps forward. |
pause |
The autoplay control. |
previous |
The control that steps back. |
status |
The live region. |
viewport |
The scrolling container. |
| Property | Description |
|---|---|
--bmx-carousel-control-background |
Their fill. |
--bmx-carousel-control-border |
Their outline. |
--bmx-carousel-control-color |
Their glyph. |
--bmx-carousel-control-size |
The diameter of the previous and next controls. |
--bmx-carousel-dot-color |
A dot that is not current. |
--bmx-carousel-dot-current-color |
The current one. |
--bmx-carousel-dot-gap |
The space between dots. |
--bmx-carousel-dot-size |
The diameter of one dot. |
--bmx-carousel-focus-ring |
The focus indicator. |
--bmx-carousel-gap |
The space between slides. |
--bmx-carousel-per-view |
How many slides fit across. Set from the per-view property. |
--bmx-carousel-radius |
The corner radius of the viewport. |
--bmx-carousel-tab-padding |
The room inside a tab, in tabs mode. |