component
Carousel
A card for a carousel: a number, a title and a short description.
Hover, press or tab to one to see the other 3 states.
import { Carousel } from 'rost/components/carousel/Carousel'
<Carousel href="/work/akount" number="01" title="akount.ing" description="Bookkeeping that explains itself." />src/components/carousel/Carousel.tsx
import type { AnchorHTMLAttributes, ReactNode } from 'react'
import './carousel.css'
export interface CarouselProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'title'> {
/** A two-digit index in front of the title. */
readonly number?: ReactNode
readonly title?: ReactNode
readonly description?: ReactNode
readonly showNumber?: boolean
readonly showTitle?: boolean
readonly showDescription?: boolean
/** Fades the card and drops the link. */
readonly disabled?: boolean
/** `div` draws it without the link, for a preview inside something that's already a link. */
readonly as?: 'a' | 'div'
}
const b = 'rost-carousel'
/**
* One card in a carousel of work: a number, a title and a line of
* description. The whole card is a link; hover, press and focus are the
* browser's. Built from contracts/carousel.contract.yaml.
*/
export function Carousel({
number = '00', title = 'Title', description = 'Description',
showNumber = true, showTitle = true, showDescription = true,
disabled = false, className, href, as = 'a', ...rest
}: CarouselProps) {
const Root = as
const link = as === 'a' ? { href: disabled ? undefined : href, 'aria-disabled': disabled || undefined } : { 'aria-disabled': disabled || undefined }
return (
<Root className={[b, className].filter(Boolean).join(' ')} {...link} {...(rest as object)}>
<span className={`${b}__background`} />
<span className={`${b}__title`}>
<span className={`${b}__number`}>{showNumber && number}</span>
{showTitle && <span className={`${b}__text`}>{title}</span>}
</span>
<span className={`${b}__bottom`}>
{showDescription && <span className={`${b}__description`}>{description}</span>}
</span>
<span className={`${b}__focus-ring`} />
</Root>
)
}When to use
- Used for a row of featured items you scroll through, each card leading to more.
When not to use
- A card with a picture. That's Thumbs.
- Long titles. The title wraps, but two lines of 24px eat the card.
Behaviour
A number, a title and a line of description, on nothing: the card has no fill until you press it. On hover the number and title go green and the description lightens. Pressed lays a frosted glass under it (effect/thumb). Disabled fades the whole card to opacity/mild.
Accessibility
- Element: an
<a>. Disabled drops thehrefand setsaria-disabled="true". - Name: the title, read with the number and the description. Pass
aria-labelif that reads badly. - Keyboard: Tab to it, Enter opens it. The carousel around it handles the arrows.
- Focus: a 2px ring, 4px outside the card, on
:focus-visibleonly.
Content
A title of one to three words. The description is one short sentence.
Props
| Prop | Type | Values | Default |
|---|---|---|---|
| number | slot | ||
| title | slot | ||
| description | slot | ||
| showNumber | boolean | true | |
| showTitle | boolean | true | |
| showDescription | boolean | true |
States: Default, Hover, Pressed, Focus, Disabled.
Values
Straight from the contract. Each value sits next to its token, because six tokens here all come out as #3a3a3a and the colour alone can't tell you which one is right.
Never changes
| Part | Property | Value | Token |
|---|---|---|---|
| root | display | flex | layout |
| root | flex-direction | column | layout |
| root | align-items | flex-start | layout |
| root | position | relative | layout |
| root | gap | 0px | spacing/gap/none |
| root | padding | 24px | spacing/gap/large |
| root | border-radius | 24px | spacing/radius/xlarge |
| root | text-decoration | none | Figma frame size |
| root | color | inherit | Figma frame size |
| root | outline | none | Figma frame size |
| root | width | 312px | Figma frame size |
| background | display | none | layout |
| background | position | absolute | layout |
| background | inset | 0 | layout |
| background | background | color-mix(in srgb, var(--non-interactive-background-tertiary) calc(var(--opacity-medium) * 100%), transparent) | on purpose: Composed from tokens: non-interactive/background/tertiary at opacity/medium. |
| background | backdrop-filter | blur(12px) | effect/thumb |
| background | opacity | 0.64 | opacity/strong |
| background | border-radius | 24px | spacing/radius/xlarge |
| background | pointer-events | none | Figma frame size |
| title | align-items | center | layout |
| title | display | flex | layout |
| title | position | relative | layout |
| title | width | 100% | layout |
| title | gap | 16px | spacing/gap/medium |
| number | display | flex | layout |
| number | justify-content | center | layout |
| number | align-items | center | layout |
| number | flex-shrink | 0 | layout |
| number | width | 16px | spacing/size/2xsmall |
| number | font-family | 'General Sans Variable' | font-family/base |
| number | font-weight | 400 | typography/label/font-weight/regular |
| number | line-height | 14px | typography/label/line-height |
| number | font-size | 12px | typography/label/font-size |
| number | letter-spacing | 0px | typography/label/letter-spacing |
| text | flex | 1 0 0 | layout |
| text | min-width | 1px | Figma frame size |
| text | font-family | 'General Sans Variable' | font-family/base |
| text | font-weight | 500 | typography/title/font-weight/medium |
| text | line-height | 32px | typography/title/line-height |
| text | font-size | 24px | typography/title/font-size |
| text | letter-spacing | 0px | typography/title/letter-spacing |
| bottom | align-items | center | layout |
| bottom | display | flex | layout |
| bottom | position | relative | layout |
| bottom | width | 100% | layout |
| bottom | justify-content | flex-end | layout |
| bottom | gap | 16px | spacing/gap/medium |
| bottom | padding-left | 32px | spacing/gap/xlarge |
| description | flex | 1 0 0 | layout |
| description | min-width | 1px | Figma frame size |
| description | font-family | 'General Sans Variable' | font-family/base |
| description | font-weight | 400 | typography/paragraph/font-weight/regular |
| description | line-height | 20px | typography/paragraph/line-height |
| description | font-size | 14px | typography/paragraph/font-size |
| description | letter-spacing | 0px | typography/paragraph/letter-spacing |
| focus-ring | display | none | layout |
| focus-ring | position | absolute | layout |
| focus-ring | inset | calc(-1 * var(--spacing-gap-2xsmall)) | layout |
| focus-ring | border-style | solid | no token yet |
| focus-ring | border-width | 2px | spacing/outline/medium |
| focus-ring | border-color | #999 | project/focus/focus-ring |
| focus-ring | border-radius | calc(var(--spacing-radius-xlarge) + var(--spacing-gap-2xsmall)) | on purpose: Composed from tokens: the element's spacing/radius/xlarge plus the ring's spacing/gap/2xsmall. |
| focus-ring | pointer-events | none | Figma frame size |
Changes by variant
background.display1
| state=Pressed | block | layout |
description.color5
| state=Default | #999 | project/default/sub-label |
| state=Hover | #ededed | project/hover/sub-label |
| state=Pressed | #c2c7c3 | project/pressed/sub-label |
| state=Focus | #999 | project/focus/sub-label |
| state=Disabled | #999 | project/disabled/sub-label |
focus-ring.display1
| state=Focus | block | layout |
number.color5
| state=Default | #999 | project/default/sub-label |
| state=Hover | #00d669 | project/hover/label |
| state=Pressed | #00d669 | project/pressed/label |
| state=Focus | #999 | project/focus/sub-label |
| state=Disabled | #999 | project/disabled/sub-label |
root.cursor1
| state=Disabled | default | Figma frame size |
root.opacity1
| state=Disabled | 0.32 | opacity/mild |
text.color5
| state=Default | #d3d9d5 | project/default/label |
| state=Hover | #00d669 | project/hover/label |
| state=Pressed | #00d669 | project/pressed/label |
| state=Focus | #d3d9d5 | project/focus/label |
| state=Disabled | #d3d9d5 | project/disabled/label |