Camp card draft
The responsive, tokenized card for a single camp session — the unit the location-detail page lists. Composes a 16:9 media header with an unmasked club-logo viewport, a Beestera calendar-date tile, the camp title, an availability badge with an inventory count, descriptive tags, an optional goalkeeper-training feature callout, location/dates/club metadata rows, and a price block paired with a Register button. The whole card is the camp-details link; Register stays a separate primary action.
Source: specs/camp-card/spec.json · specs/camp-card/notes.md
Anatomy
root- The card surface: an emphasis-dependent fill (bg-primary for Standard, bg-brand-primary for Residential), a card hairline, the generic card radius, content clipped. Fluid between 280px and 460px wide (360px is the Figma reference width).
featured-border- A 4px brand rule across the very top, shown only for a featured camp.
media- The 16:9 camp photo (360x202.5 at the reference width — an exact ratio, not the rounded 360x203 the frame previously used), cover-cropped, with a centered 70% subject-safe area. When no photo is published it falls back to a clearly-marked image placeholder on bg-secondary — a marked placeholder, never stock or mock photography. The photo is retained in every state, including Sold out.
club-logo- A 48x48 radius-xl viewport pinned 16px from the media's bottom-right, holding 40x40 partner artwork scaled to Fit — never Crop, so irregular crests and wordmarks are not clipped. Unmasked on purpose. Falls back to a clearly-marked logo placeholder when the club has no artwork.
content- The padded body stack below the media — 24px inline padding, 20px block padding, 12px between groups.
calendar-date- The 54x54 Data display/Calendar date tile in Style=Beestera: brand-yellow outer stroke, brand month header over a brand-tinted day body, no internal divider. Month is a three-letter uppercase abbreviation.
title- The camp name — text-lg bold, the card's only heading.
availability-badge- The pill stating whether registration is possible: Success 'Registration Open' normally, Error 'Sold out' when State=Sold out.
remaining-spots- The free-text inventory count beside the badge ('Only 8 left'), set at text-xs semibold. Its colour encodes urgency; it is omitted entirely when inventory is healthy, and when the camp is sold out. In State=Sold out the availability area holds the 63x22 'Sold out' badge and nothing else — no empty box is reserved where the count would have been.
tags- Descriptive tags for the camp — one camp-format tag (Overnight, Extended Day or Day Camp) plus seasonal and geographic metadata. A single-line, fill-width row with clipping protection: it never wraps and never extends past the card. At the 360px reference width it shows two priority tags plus the overflow count.
tag-overflow- The Tags/Overflow count component — a compact, tokenized '+N' indicator closing the tag row, revealed by Show tag overflow. Informational, not an interactive control: the card itself is the link, so this must never become a nested button.
feature-callout- The Cards/Camp feature callout — a GK marker plus a one-line label, revealed by Show goalkeeper training. More prominent than a tag, secondary to price and registration.
essential-details- Location, dates/ages and club rows, each a 24x24 Beestera icon (location, calendar, cleat) beside text-xs metadata.
price- A 'From' eyebrow with an optional Early bird badge, then an optional struck-through previous price followed by the current price and its unit.
early-bird-badge- The promotional Early bird pill in the price eyebrow — brand-toned, never a success/green tone.
action- The Register button: a brand-gradient pill on the shared Button's Primary treatment, the explicit primary action. In State=Sold out it renders the shared Button's Disabled treatment — a bg-quaternary fill with text-quaternary label, no gradient — and accepts neither pointer nor keyboard activation.
Token bindings
Each binding's value is resolved live from the built
@beestera/tokens output — tier-2 component tokens
(--camp-card-*) collapse through the foundation tier.
| Part | Property | Token | Resolved | Applies to |
|---|---|---|---|---|
root | background | --bg-primary | #ffffff --bg-primary → --color-base-white | campTypeEmphasis:Standard |
root | background | --bg-brand-primary | #ffffff --bg-brand-primary → --color-base-white | campTypeEmphasis:Residential |
root | border-color | --card-border | #e4e4e7 --card-border → --color-border | — |
root | border-radius | --card-radius | 0.75rem --card-radius → --radius-card | — |
root | box-shadow | --shadow-md | 0px 2px 4px -2px #0000000f, 0px 0px 10px -1px #0000001a | state:Hover |
root | box-shadow | --focus-ring | #3d3d3d --focus-ring → --color-neutral-700 | state:Focused |
featured-border | background | --bg-brand-solid | #181818 --bg-brand-solid → --color-neutral-900 | — |
media | background | --bg-secondary | #fafafa --bg-secondary → --color-neutral-50 | — |
club-logo | background | --bg-primary | #ffffff --bg-primary → --color-base-white | — |
club-logo | border-color | --card-border | #e4e4e7 --card-border → --color-border | — |
club-logo | border-radius | --radius-xl | 0.75rem | — |
content | padding-inline | --spacing-3xl | 1.5rem --spacing-3xl → --spacing-scale-6 | — |
content | padding-block | --spacing-2xl | 1.25rem --spacing-2xl → --spacing-scale-5 | — |
content | gap | --spacing-lg | 0.75rem --spacing-lg → --spacing-scale-3 | — |
calendar-date | border-color | --fg-brand-primary | #181818 --fg-brand-primary → --color-neutral-900 | — |
calendar-date | border-radius | --radius-lg | 0.875rem | — |
calendar-date | background | --bg-brand-solid | #181818 --bg-brand-solid → --color-neutral-900 | slot:month-header |
calendar-date | background | --bg-brand-primary | #ffffff --bg-brand-primary → --color-base-white | slot:day-body,campTypeEmphasis:Standard |
calendar-date | background | --bg-primary | #ffffff --bg-primary → --color-base-white | slot:day-body,campTypeEmphasis:Residential |
title | color | --text-primary | #181818 --text-primary → --color-neutral-900 | — |
title | font-size | --font-size-text-lg | 1.125rem | — |
availability-badge | border-radius | --radius-full | 9999px | — |
availability-badge | background | --utility-green-50 | #f0fdf4 --utility-green-50 → --color-green-50 | availability:Registration open |
availability-badge | border-color | --utility-green-200 | #bbf7d0 --utility-green-200 → --color-green-200 | availability:Registration open |
availability-badge | color | --utility-green-700 | #15803d --utility-green-700 → --color-green-700 | availability:Registration open |
availability-badge | background | --utility-red-50 | #fef2f2 --utility-red-50 → --color-red-50 | state:Sold out |
availability-badge | border-color | --utility-red-200 | #fecaca --utility-red-200 → --color-red-200 | state:Sold out |
availability-badge | color | --utility-red-700 | #b91c1c --utility-red-700 → --color-red-700 | state:Sold out |
remaining-spots | font-size | --font-size-text-xs | 0.75rem | — |
remaining-spots | font-weight | --font-weight-semibold | 600 | — |
remaining-spots | color | --text-secondary | #3d3d3d --text-secondary → --color-neutral-700 | availability:Healthy |
remaining-spots | color | --text-warning-primary | #ca8a04 --text-warning-primary → --color-yellow-600 | availability:Running low |
remaining-spots | color | --text-error-primary | #dc2626 --text-error-primary → --color-red-600 | availability:Nearly gone |
tags | background | --bg-primary | #ffffff --bg-primary → --color-base-white | — |
tags | border-color | --border-primary | #d4d4d8 --border-primary → --color-neutral-300 | — |
tags | border-radius | --radius-sm | 0.375rem | — |
tags | color | --text-secondary | #3d3d3d --text-secondary → --color-neutral-700 | — |
tags | font-size | --font-size-text-xs | 0.75rem | — |
tags | gap | --spacing-sm | 0.375rem --spacing-sm → --spacing-scale-1-5 | — |
tag-overflow | background | --bg-primary | #ffffff --bg-primary → --color-base-white | — |
tag-overflow | border-color | --border-primary | #d4d4d8 --border-primary → --color-neutral-300 | — |
tag-overflow | border-radius | --radius-sm | 0.375rem | — |
tag-overflow | color | --text-secondary | #3d3d3d --text-secondary → --color-neutral-700 | — |
tag-overflow | font-size | --font-size-text-xs | 0.75rem | — |
feature-callout | background | --utility-brand-50 | #fffbeb --utility-brand-50 → --color-brand-25 | — |
feature-callout | border-radius | --radius-md | 0.625rem | — |
feature-callout | color | --text-primary | #181818 --text-primary → --color-neutral-900 | — |
feature-callout | font-size | --font-size-text-sm | 0.875rem | — |
feature-callout | background | --bg-brand-solid | #181818 --bg-brand-solid → --color-neutral-900 | slot:gk-marker |
essential-details | color | --text-secondary | #3d3d3d --text-secondary → --color-neutral-700 | — |
essential-details | font-size | --font-size-text-xs | 0.75rem | — |
essential-details | gap | --spacing-sm | 0.375rem --spacing-sm → --spacing-scale-1-5 | — |
price | color | --text-tertiary | #52525b --text-tertiary → --color-neutral-600 | slot:eyebrow |
price | color | --text-tertiary | #52525b --text-tertiary → --color-neutral-600 | slot:previous-price |
price | color | --text-primary | #181818 --text-primary → --color-neutral-900 | slot:current-price |
price | font-size | --font-size-text-xl | 1.25rem | slot:current-price |
price | color | --text-secondary | #3d3d3d --text-secondary → --color-neutral-700 | slot:price-unit |
early-bird-badge | background | --utility-brand-50 | #fffbeb --utility-brand-50 → --color-brand-25 | — |
early-bird-badge | border-color | --utility-brand-900 | #c27c00 --utility-brand-900 → --color-brand-800 | — |
early-bird-badge | color | --utility-brand-900 | #c27c00 --utility-brand-900 → --color-brand-800 | — |
early-bird-badge | border-radius | --radius-full | 9999px | — |
action | background | --gradient-brand-500-600 | linear-gradient(180deg, var(--color-brand-500) 0%, var(--color-brand-600) 100%) | — |
action | color | --text-primary | #181818 --text-primary → --color-neutral-900 | — |
action | background | --button-disabled-bg | #d4d4d8 --button-disabled-bg → --bg-quaternary → --color-neutral-300 | state:Sold out |
action | color | --button-disabled-fg | #71717a --button-disabled-fg → --text-quaternary → --color-neutral-500 | state:Sold out |
action | font-size | --font-size-text-md | 1rem | — |
action | border-radius | --radius-full | 9999px | — |
action | box-shadow | --button-shadow | 0px 1px 2px 0px #0000000d, inset 0px -2px 0px 0px #0000000d, inset 0px 0px 0px 1px #0000002e --button-shadow → --shadow-xs-skeuomorphic | — |
Variants
-
stateDefaultHoverFocusedSold out - The Figma State axis. Default/Hover/Focused are the card details link's interaction states. Sold out is NOT an interaction state — it is the single source of truth for zero inventory: the Error 'Sold out' badge replaces the Success badge, the photo and every other detail stay fully readable, and Register renders the shared Button's Disabled treatment. Never model sold-out as a separate overlay or boolean alongside this axis. There is deliberately no card-level Disabled state: a finished camp leaves the active Browse Camps inventory rather than sitting in the list greyed out.
-
campTypeEmphasisStandardResidential - Standard is the day-camp presentation on a bg-primary (white) surface. Residential identifies the broader residential program family and applies its branded treatment: the card surface becomes bg-brand-primary, and the calendar-date tile inverts its day-body to bg-primary so the tile stays legible against the tinted card. Structure, spacing and every other token are shared with Standard. Residential is an emphasis, NOT a schedule-format tag — see the tags part for the format vocabulary.
-
showTagOverflowtruefalse - The Figma boolean revealing the Tags/Overflow count component at the end of the tag row. True whenever the camp has more tags than fit the row at the current width; the count itself is an editable Count property (+1, +2, +3...) in Figma and a computed value in code.
-
availabilityRegistration openHealthyRunning lowNearly gone - Derived from live inventory, not chosen as a Figma variant property. 'Registration open' is the Success badge shown whenever the camp is not sold out. The other three tier the remaining-spots count: Healthy (>25% AND >10 spaces) stays neutral or omits the count entirely; Running low (11-25% OR 4-10 spaces) uses warning text; Nearly gone (<=10% OR 1-3 spaces) uses error text. When percentage and count disagree, take the more urgent of the two.
State matrix
default- Resting card. The whole surface is the camp-details link; Register sits inside it as a separate control.
hover- The card details link lifts to the medium elevation (--shadow-md). Hovering Register is the button's own concern.
focus- Keyboard focus on the card details link paints the shared focus ring — a 4px ring offset from the card by a 2px bg-primary halo.
Accessibility
- Role
- The root is a link to the camp-details page (a real <a>, or a card-wide link overlay). Register is a separate <button>/<a> that MUST NOT be nested inside the card link — two controls, not one.
- Contrast
- Badge labels sit on their own tint at >= 4.5:1 (green-700 on green-50, red-700 on red-50, brand-900 on brand-50). The availability urgency tiers are colour-only signals, so the count text must always state the number ('Only 3 left') — colour never carries the meaning alone.
- Focus order
- Card details link first, then Register. The Focused variant is the card link's focus, not the button's; each control keeps its own visible ring.
- Target size
- Register is 44px tall in the Figma reference and must stay >= 44px. The card link's own target is the full card, so it always clears the minimum.
- Sold out must expose registration as unavailable programmatically (a disabled button, or aria-disabled plus a non-interactive handler) — dimming it visually is not enough.
- Never add a sold-out scrim or overlay over the media: the description is explicit that content stays readable and the Error badge is the only signal.
- The media photo is decorative — the title carries the camp name, so the image takes an empty alt.
- The club logo is decorative whenever the club name is already present in the essential-details rows; otherwise it needs the club name as its accessible name.
- Tag overflow ('+3') hides real content from screen-reader users. The indicator must name what it hides — aria-label="3 more: Day Camp, Summer, NY" — and the complete tag list must be present on the details page.
- The '+N' indicator is informational, never a nested interactive control: the card is already a link, and a button inside it would be a second tab stop to the same destination.
Usage
Do
- Use this card on the location-detail page, where each entry is one bookable camp session. The browse page uses the camp location card instead.
- Drive Sold out from capacity being reached, not from a stored 'closed' status.
- Keep the count text honest and specific ('Only 3 left'), and drop it entirely when inventory is healthy.
- Compute the visible tags dynamically rather than hard-coding two: preserve the tag source order, prioritise camp-format tags ahead of seasonal and geographic metadata, reserve space for the '+N' indicator, show as many COMPLETE tags as fit, then set N = total tags - visible tags. Recalculate whenever the card width or the tag content changes.
- Give a Residential camp exactly one schedule-format tag — Overnight (24-hour supervision and accommodation), Extended Day (9am-9pm) or Day Camp (9am-3pm).
- Keep the previous price muted and struck through, and the current price bold and primary.
Don't
- Don't add a separate sold-out boolean, overlay or scrim — State=Sold out is the only source of truth for zero inventory.
- Don't colour the Early bird badge with a success/green tone; it is promotional Brand.
- Don't nest the Register button inside the card's details link.
- Don't mask or crop the club logo — the 48x48 viewport is deliberately unmasked around 40x40 Fit artwork.
- Don't place 'Overnight' and 'Residential' beside each other as if they were sibling tag values: Residential is the program family (the Camp type emphasis), Overnight is one of its three schedule formats.
- Don't let a tag wrap to a second line, truncate mid-word, or extend past the card edge — the row is single-line and clipped, and drops to one visible tag plus '+N' on narrow screens.
- Don't put session-level inventory or exact ages on the location card — they belong here.
Implementations
Not implemented in any product repo yet. This repo owns the spec; implementations land in each product repo on their own cadence.
Figma source: Cards/Camp card