Thumbs
A project thumbnail card, with the project name and details on a frosted panel at the bottom.
Hover, press or tab to one to see the other 3 states.
import { Thumbs } from 'rost/components/thumbs/Thumbs'
<Thumbs href="/work/akount" number="01" name="akount.ing" industry="Finance" kind="Product" period="2025">
<img src="/work/akount.jpg" alt="" />
</Thumbs>
<Thumbs disabled number="02" name="Next one" industry="Health" kind="Platform" period="2026" />src/components/thumbs/Thumbs.tsx
import { Fragment, type AnchorHTMLAttributes, type ReactNode } from 'react'
import { Badge } from '../badge/Badge'
import './thumbs.css'
export interface ThumbsProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
/** The project's name, in the title. */
readonly name?: ReactNode
/** A two-digit index in front of the name. */
readonly number?: ReactNode
readonly industry?: ReactNode
/** What kind of work it was. Figma calls this detail `type`. */
readonly kind?: ReactNode
readonly period?: ReactNode
readonly showNumber?: boolean
readonly showName?: boolean
readonly showIndustry?: boolean
readonly showKind?: boolean
readonly showPeriod?: boolean
/** Dims the card, drops the link and shows `badge`. */
readonly disabled?: boolean
/** The pill on a disabled card. */
readonly badge?: ReactNode
/** Puts the green lock in the corner, for work behind a password. */
readonly locked?: boolean
/** What fills the card: an image, usually. */
readonly children?: ReactNode
/**
* `div` draws the card without the link, for a preview inside something
* that's already a link (a link can't hold another). It has no states then.
*/
readonly as?: 'a' | 'div'
}
const b = 'rost-thumbs'
/**
* A project card. The whole card is one link; hover, pressed and focus are
* the browser's own states. Built from contracts/thumbs.contract.yaml,
* following the Figma layers.
*/
export function Thumbs({
name = 'Name', number = '00', industry = 'Industry', kind = 'Type', period = 'Period',
showNumber = true, showName = true, showIndustry = true, showKind = true, showPeriod = true,
disabled = false, badge = 'Coming soon', locked = false, children, className, href, as = 'a', ...rest
}: ThumbsProps) {
const cls = [b, locked && `${b}--state-locked`, className].filter(Boolean).join(' ')
// The details with a dot between each pair, whichever are shown.
const details = [showIndustry && industry, showKind && kind, showPeriod && period].filter((d) => d !== false)
const Root = as
const link = as === 'a' ? { href: disabled ? undefined : href, 'aria-disabled': disabled || undefined } : { 'aria-disabled': disabled || undefined }
return (
<Root className={cls} {...link} {...(rest as object)}>
<span className={`${b}__slot`}>{children}</span>
<span className={`${b}__shadow`} />
<span className={`${b}__focus-ring`} />
<span className={`${b}__content`}>
<span className={`${b}__title`}>
<span className={`${b}__number`}>{showNumber && number}</span>
{showName && <span className={`${b}__text`}>{name}</span>}
</span>
<span className={`${b}__bottom`}>
{details.map((d, i) => (
<Fragment key={i}>
{i > 0 && <span className={`${b}__icons-dot`} aria-hidden="true"><span className={`${b}__dot`} /></span>}
<span className={`${b}__detail`}>{d}</span>
</Fragment>
))}
</span>
</span>
{disabled && badge && <span className={`${b}__badge`}><Badge>{badge}</Badge></span>}
{locked && (
<span className={`${b}__locked`}>
<span className={`${b}__icons-lock`} role="img" aria-label="Locked" />
</span>
)}
</Root>
)
}When to use
- Used as a card for one project or item in a grid or a carousel. The whole card links to the details.
- A list of things that each want a picture and a short line of facts under a name.
When not to use
- A card that isn't a link. Thumbs has hover, pressed and focus because it's meant to be clicked.
- Long names. The title doesn't wrap, by design. Two or three words.
Behaviour
The picture fills the card, and the frosted panel sits on its bottom edge with the number, the name and three details. On hover and pressed the name and number go green, and the details lighten. Disabled dims the picture and the panel and shows the badge ("Coming soon"). Locked keeps the card as it is and puts a green lock in the corner, for work I can't show without a password.
Accessibility
- Element: an
<a>. Disabled drops thehrefand setsaria-disabled="true". - Name: the link's text, which is the number, the name and the details read in order. Pass
aria-labelif that reads badly. - Keyboard: Tab to it, Enter opens it.
- Focus: a 2px ring, 2px outside the card, on
:focus-visibleonly. - Colour: Locked says it with the lock icon, which has its own text ("Locked") for screen readers.
Content
The name is the client or the product, as people know it. Details are a word or two each, the period like "2024" or "2023 to 2025".
Props
| Prop | Type | Values | Default |
|---|---|---|---|
| name | slot | ||
| number | slot | ||
| industry | slot | ||
| type | slot | ||
| period | slot | ||
| showNumber | boolean | true | |
| showName | boolean | true | |
| showIndustry | boolean | true | |
| showType | boolean | true | |
| showPeriod | boolean | true | |
| image | slot | ||
| badge | slot |
States: Default, Hover, Pressed, Focus, Disabled, Locked.
Where it's used
- runceanu.com
src/rost/thumbs/Thumbs.tsx.rost-thumbs
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 | 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 | height | 334px | Figma frame size |
| root | width | 384px | Figma frame size |
| slot | flex | 1 0 0 | layout |
| slot | min-height | 1px | Figma frame size |
| slot | width | 100% | layout |
| slot | position | relative | layout |
| slot | overflow | hidden | layout |
| slot | background | #171717 | non-interactive/background/secondary |
| slot | border-radius | 24px | spacing/radius/xlarge |
| shadow | position | absolute | layout |
| shadow | left | 0 | no token yet |
| shadow | right | 0 | no token yet |
| shadow | bottom | 0 | no token yet |
| shadow | height | 50% | no token yet |
| shadow | background | linear-gradient(to bottom, rgba(6,8,5,0), var(--non-interactive-background-primary)) | on purpose: A gradient from the page background, transparent, to the page background. The colour is the token; the ramp is the effect. |
| shadow | opacity | 0.48 | opacity/medium |
| shadow | border-bottom-left-radius | 24px | spacing/radius/xlarge |
| shadow | border-bottom-right-radius | 24px | spacing/radius/xlarge |
| shadow | pointer-events | none | Figma frame size |
| content | position | absolute | layout |
| content | left | 8px | spacing/gap/small |
| content | right | 8px | spacing/gap/small |
| content | bottom | 8px | spacing/gap/small |
| content | display | flex | layout |
| content | flex-direction | column | layout |
| content | align-items | flex-start | layout |
| content | background | color-mix(in srgb, var(--base-black) calc(var(--opacity-medium) * 100%), transparent) | on purpose: Composed from tokens: base/black at opacity/medium. |
| content | backdrop-filter | blur(12px) | effect/thumb |
| content | gap | 8px | spacing/gap/small |
| content | padding-bottom | 24px | spacing/gap/large |
| content | padding-top | 16px | spacing/gap/medium |
| content | padding-left | 16px | spacing/gap/medium |
| content | padding-right | 16px | spacing/gap/medium |
| content | border-radius | 16px | spacing/radius/large |
| title | align-items | center | layout |
| title | display | flex | layout |
| title | flex-shrink | 0 | layout |
| title | width | 100% | layout |
| title | position | relative | layout |
| title | gap | 16px | spacing/gap/medium |
| number | justify-content | center | layout |
| number | align-items | center | layout |
| number | display | flex | layout |
| number | flex-shrink | 0 | layout |
| number | position | relative | 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-shrink | 0 | layout |
| text | white-space | nowrap | layout |
| text | position | relative | layout |
| 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 | flex-shrink | 0 | layout |
| bottom | justify-content | flex-end | layout |
| bottom | position | relative | layout |
| bottom | gap | 16px | spacing/gap/medium |
| bottom | padding-left | 32px | spacing/gap/xlarge |
| detail | flex-shrink | 0 | layout |
| detail | white-space | nowrap | layout |
| detail | position | relative | layout |
| detail | font-family | 'General Sans Variable' | font-family/base |
| detail | font-weight | 400 | typography/label/font-weight/regular |
| detail | line-height | 14px | typography/label/line-height |
| detail | font-size | 12px | typography/label/font-size |
| detail | letter-spacing | 0px | typography/label/letter-spacing |
| icons-dot | display | flex | layout |
| icons-dot | align-items | center | layout |
| icons-dot | justify-content | center | layout |
| icons-dot | flex-shrink | 0 | layout |
| icons-dot | position | relative | layout |
| icons-dot | width | 12px | spacing/size/3xsmall |
| icons-dot | height | 12px | spacing/size/3xsmall |
| dot | width | 3px | on purpose: The dot's own size inside the icon. |
| dot | height | 3px | on purpose: The dot's own size inside the icon. |
| dot | border-radius | 10000px | spacing/radius/full |
| 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 |
| badge | position | absolute | layout |
| badge | top | 16px | spacing/gap/medium |
| badge | right | 16px | spacing/gap/medium |
| badge | display | flex | layout |
| locked | position | absolute | layout |
| locked | top | 16px | spacing/gap/medium |
| locked | right | 16px | spacing/gap/medium |
| locked | align-items | center | layout |
| locked | display | flex | layout |
| locked | background | #00d669 | non-interactive/background/positive/base |
| locked | gap | 0px | spacing/gap/none |
| locked | padding | 8px | spacing/gap/small |
| locked | border-radius | 12px | spacing/radius/medium |
| icons-lock | display | block | layout |
| icons-lock | flex-shrink | 0 | layout |
| icons-lock | position | relative | layout |
| icons-lock | width | 24px | spacing/size/small |
| icons-lock | height | 24px | spacing/size/small |
| icons-lock | background | #060805 | non-interactive/foreground/contrast |
| icons-lock | mask | url(./icon-lock.svg) center / contain no-repeat | on purpose: The icon file itself. |
Changes by variant
content.opacity1
| state=Disabled | 0.32 | opacity/mild |
detail.color6
| 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 |
| state=Locked | #999 | project/default/sub-label |
dot.background5
| state=Default | #747573 | project/default/icon |
| state=Hover | #ededed | project/hover/icon |
| state=Pressed | #c2c7c3 | project/pressed/icon |
| state=Focus | #747573 | project/focus/icon |
| state=Disabled | #747573 | project/disabled/icon |
focus-ring.display1
| state=Focus | block | layout |
number.color6
| 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 |
| state=Locked | #999 | project/default/sub-label |
root.cursor1
| state=Disabled | default | Figma frame size |
slot.opacity1
| state=Disabled | 0.32 | opacity/mild |
text.color6
| 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 |
| state=Locked | #d3d9d5 | project/default/label |