Projects
One row in the project list: number, name, and industry, type and period on the right.
Hover, press or tab to one to see the other 3 states.
import { Projects } from 'rost/components/projects/Projects'
<Projects href="/work/akount" number="01" name="akount.ing" industry="Finance" kind="Product" period="2025" />
<Projects locked number="02" name="Client 1" industry="Health" kind="Platform" period="2024" />src/components/projects/Projects.tsx
import { Fragment, type AnchorHTMLAttributes, type ReactNode } from 'react'
import { Badge } from '../badge/Badge'
import './projects.css'
export interface ProjectsProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
/** A two-digit index in front of the name. */
readonly number?: ReactNode
readonly name?: 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
/** The arrow at the end of the row. */
readonly showAction?: boolean
/** The line under the row. */
readonly showDivider?: boolean
/** Fades the row, drops the link and shows `badge`. */
readonly disabled?: boolean
/** The pill after the name on a disabled row. */
readonly badge?: ReactNode
/** Puts a green lock after the name, for work behind a password. */
readonly locked?: boolean
/** `div` draws it without the link, for a preview inside something that's already a link. */
readonly as?: 'a' | 'div'
}
const b = 'rost-projects'
/**
* One row in a list of projects. The whole row is a link; hover, press and
* focus are the browser's. Built from contracts/projects.contract.yaml.
*/
export function Projects({
number = '00', name = 'Name', industry = 'Industry', kind = 'Type', period = 'Period',
showNumber = true, showName = true, showIndustry = true, showKind = true, showPeriod = true,
showAction = true, showDivider = true, disabled = false, badge = 'Coming soon', locked = false,
className, href, as = 'a', ...rest
}: ProjectsProps) {
const cls = [b, locked && `${b}--state-locked`, className].filter(Boolean).join(' ')
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)}>
{showDivider && <span className={`${b}__outline`} />}
<span className={`${b}__left`}>
{showNumber && <span className={`${b}__number`}>{number}</span>}
{showName && <span className={`${b}__text`}>{name}</span>}
{locked && <span className={`${b}__icons-lock`} role="img" aria-label="Locked" />}
{disabled && badge && <span className={`${b}__badge`}><Badge>{badge}</Badge></span>}
</span>
<span className={`${b}__right`}>
{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>
{showAction && <span className={`${b}__actions`} aria-hidden="true"><span className={`${b}__icons-arrow`} /></span>}
<span className={`${b}__focus-ring`} />
</Root>
)
}When to use
- Used as a list of projects or items, one row each, where the row links to the details.
When not to use
- A grid with pictures. That's Thumbs.
- Long names. At 40px, a name past three words pushes the details off the row.
Behaviour
A number and a big name on the left, industry, type and period on the right, an arrow at the end, and a line underneath. On hover and press the content slides 16px right, the name goes green, and the line turns green (2px on hover). Locked puts a green lock after the name and turns the dots green, for work behind a password. Disabled fades everything but the badge, which says "Coming soon".
Accessibility
- Element: an
<a>. Disabled drops thehrefand setsaria-disabled="true". - Name: the name, then the details. Pass
aria-labelif that reads badly. - Keyboard: Tab to it, Enter opens it.
- Focus: a 2px ring, 4px outside the row, on
:focus-visibleonly. - Colour: Locked says it with the lock icon, which has its own text for screen readers.
Content
The name is the client or the product, as people know it. Details are a word or two each.
Props
| Prop | Type | Values | Default |
|---|---|---|---|
| number | slot | ||
| name | slot | ||
| industry | slot | ||
| type | slot | ||
| period | slot | ||
| showNumber | boolean | true | |
| showName | boolean | true | |
| showIndustry | boolean | true | |
| showType | boolean | true | |
| showPeriod | boolean | true | |
| showAction | boolean | true | |
| showDivider | boolean | true | |
| badge | slot |
States: Default, Hover, Pressed, Focus, Disabled, Locked.
Where it's used
- runceanu.com
src/rost/projects/Projects.tsx.rost-projects
Where code differs on purpose
- runceanu.com:The last row in the Work list has no line at rest; hover and press still draw it. The 16px slide is eased over 0.2s.The list ends at the footer, and a line there reads as a stray border. Motion isn't in the contract.
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 | align-items | center | layout |
| root | position | relative | layout |
| root | gap | 40px | spacing/gap/2xlarge |
| root | padding-top | 24px | spacing/gap/large |
| root | padding-bottom | 24px | spacing/gap/large |
| root | padding-left | 0px | spacing/gap/none |
| root | padding-right | 0px | spacing/gap/none |
| root | text-decoration | none | Figma frame size |
| root | color | inherit | Figma frame size |
| root | outline | none | Figma frame size |
| root | width | 976px | Figma frame size |
| outline | position | absolute | layout |
| outline | left | 0 | no token yet |
| outline | right | 0 | no token yet |
| outline | bottom | 0 | no token yet |
| outline | pointer-events | none | Figma frame size |
| left | display | flex | layout |
| left | align-items | center | layout |
| left | flex | 1 0 0 | layout |
| left | min-width | 1px | Figma frame size |
| left | gap | 40px | spacing/gap/2xlarge |
| left | white-space | nowrap | layout |
| number | flex-shrink | 0 | layout |
| 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 | font-family | 'General Sans Variable' | font-family/base |
| text | font-weight | 500 | typography/sub-headline/font-weight/medium |
| text | line-height | 48px | typography/sub-headline/line-height |
| text | font-size | 40px | typography/sub-headline/font-size |
| text | letter-spacing | 0px | typography/sub-headline/letter-spacing |
| icons-lock | display | block | layout |
| icons-lock | flex-shrink | 0 | layout |
| icons-lock | width | 24px | spacing/size/small |
| icons-lock | height | 24px | spacing/size/small |
| icons-lock | background | #00d669 | project/lock/icon |
| icons-lock | mask | url(./icon-lock.svg) center / contain no-repeat | on purpose: The icon file itself. |
| badge | display | flex | layout |
| badge | flex-shrink | 0 | layout |
| right | display | flex | layout |
| right | align-items | center | layout |
| right | justify-content | flex-end | layout |
| right | align-self | stretch | layout |
| right | gap | 16px | spacing/gap/medium |
| detail | flex-shrink | 0 | layout |
| detail | white-space | nowrap | 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 | 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 |
| dot | background | #747573 | project/default/icon |
| actions | display | flex | layout |
| actions | align-items | center | layout |
| actions | justify-content | center | layout |
| actions | flex-shrink | 0 | layout |
| actions | width | 32px | spacing/size/small-medium |
| actions | height | 32px | spacing/size/small-medium |
| actions | border-radius | 10000px | spacing/radius/full |
| icons-arrow | display | block | layout |
| icons-arrow | width | 16px | spacing/size/2xsmall |
| icons-arrow | height | 16px | spacing/size/2xsmall |
| icons-arrow | background | #00d669 | actions/borderless/default/icon |
| icons-arrow | mask | url(./icon-up-right.svg) center / contain no-repeat | on purpose: The icon file itself. |
| 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 | pointer-events | none | Figma frame size |
Changes by variant
actions.opacity1
| state=Disabled | 0.24 | opacity/gentle |
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/lock/sub-label |
dot.background3
| state=Hover | #ededed | project/hover/icon |
| state=Pressed | #c2c7c3 | project/pressed/icon |
| state=Locked | #00d669 | project/lock/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/default/sub-label |
| state=Disabled | #999 | project/default/sub-label |
| state=Locked | #999 | project/lock/sub-label |
number.opacity1
| state=Disabled | 0.24 | opacity/gentle |
outline.background6
| state=Default | #3a3a3a | project/default/outline |
| state=Hover | #00d669 | project/hover/outline |
| state=Pressed | #10ffa4 | project/pressed/outline |
| state=Focus | #3a3a3a | project/focus/outline |
| state=Disabled | #3a3a3a | project/disabled/outline |
| state=Locked | #3a3a3a | project/lock/outline |
outline.height6
| state=Default | 1px | spacing/outline/small |
| state=Hover | 2px | spacing/outline/medium |
| state=Pressed | 1px | spacing/outline/small |
| state=Focus | 1px | spacing/outline/small |
| state=Disabled | 1px | spacing/outline/small |
| state=Locked | 1px | spacing/outline/small |
outline.opacity1
| state=Disabled | 0.24 | opacity/gentle |
right.opacity1
| state=Disabled | 0.24 | opacity/gentle |
root.cursor1
| state=Disabled | default | Figma frame size |
root.padding-left2
| state=Hover | 16px | spacing/gap/medium |
| state=Pressed | 16px | spacing/gap/medium |
text.color6
| state=Default | #d3d9d5 | project/default/label |
| state=Hover | #00d669 | project/hover/label |
| state=Pressed | #00d669 | project/pressed/label |
| state=Focus | #d3d9d5 | project/default/label |
| state=Disabled | #d3d9d5 | project/default/label |
| state=Locked | #d3d9d5 | project/lock/label |
text.opacity1
| state=Disabled | 0.24 | opacity/gentle |