# Obelism — Components Guide

> How we build and read components across Obelism projects. Pair this with
> [`/base.css`](/base.css) (the tokens) and [`/design.md`](/design.md) (the
> brand). Point an LLM or coding agent here so components come out
> HTML-first, token-driven, and consistent — not bespoke every time.

## Philosophy

1. **HTML first.** Reach for the semantic element before a `<div>`. A button
   is `<button>`, a link is `<a>`, a disclosure is `<details>/<summary>`, a
   list is a list. You get keyboard, focus, and screen-reader behavior for
   free — don't rebuild it in JS.
2. **Style with tokens, not values.** Colors, radii, type, and spacing come
   from `base.css` custom properties. A component should theme itself in light
   and dark for free by using the semantic `--theme-*` tokens
   (`rgb(var(--theme-accent-rgb))`), never a raw hex.
3. **CSS over JS for looks and motion.** Hover, focus, active, open/closed —
   do it in CSS (`:hover`, `:focus-visible`, `[data-*]`, `[open]`). Keep JS
   for state and data, not for what a stylesheet can express.
4. **Variants are attributes.** Switch a component's look with a
   `data-*` attribute (`data-theme="primary"`), not a pile of boolean classes.
   It reads like HTML and stays flat.
5. **As few wrappers as possible.** Every extra element is a thing to style
   and keep accessible. Add one only when layout or animation truly needs it.
6. **Progressive enhancement.** The component must be usable before JS runs
   and with motion disabled. Enhancements layer on; they're never the baseline.

## Naming & structure

- Base element classes describe the thing (`.button`, `.card`), scoped by the
  element + `data-*` for state/variant.
- Reuse the typography utilities from `base.css` (`.label-1`, `.heading-2`)
  rather than re-declaring font-size/line-height on each component.
- In a component-scoped setup (CSS Modules etc.), keep the same shapes — a
  `.container` root, `data-theme` variants, tokens from `base.css`.

## Motion style

One motion system, defined once in `base.css`. The rules (when to animate,
which curve, how long) are in [`/design.md`](/design.md#motion); these are the
recipes the products ship.

- **Easing:** `--ease-out` = `cubic-bezier(0.23, 1, 0.32, 1)` for anything
  entering or responding, `--ease-in-out` = `cubic-bezier(0.77, 0, 0.175, 1)`
  for on-screen movement, `--ease-roll` for the button roll. Never `ease-in`.
- **Duration:** `--duration-press` (160 ms), `--duration-base` (0.25 s),
  `--duration-enter` (300 ms). Exits run quicker than entrances.
- **Stagger:** offset each item by its index, capped —
  `calc(min(var(--stagger-index, 0), 8) * 50ms)` for list items,
  `calc(var(--roll-index) * 18ms)` for the button's characters.
- **Write a comment** above each animation with its numbers and why
  (`/* 20px rise, 50ms apart, on a strong ease-out: most of the movement is in
  the first 100ms */`). The next person tunes from there instead of guessing.
- **Reduced motion:** wrap movement in
  `@media (prefers-reduced-motion: no-preference)`, or give it a `reduce`
  branch that keeps the fade and drops the translate/scale.

```css
.thing {
	transition: transform var(--duration-base) var(--ease-out);
}

@media (prefers-reduced-motion: reduce) {
	.thing {
		transition: none;
	}
}
```

### Press and hover

Anything pressable gives a little on `:active`, and hover motion is for mice
only.

```css
a.card {
	transition: scale var(--duration-press) ease-out;

	&:active {
		scale: 0.98;
	}
}

.card .more {
	transition: translate 200ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
	a.card:hover .more {
		translate: 3px 0;
	}
}
```

### Entering without JavaScript (`@starting-style`)

For something that replaces what was there in one render (a form's success
message, a panel being added): transition from `@starting-style`, no
`mounted` state. Stagger lines with `nth-child` delays.

```css
.message > * {
	transition:
		opacity var(--duration-enter) var(--ease-out),
		translate var(--duration-enter) var(--ease-out);

	@starting-style {
		opacity: 0;
		translate: 0 8px;
	}

	&:nth-child(2) {
		transition-delay: 50ms;
	}

	&:nth-child(3) {
		transition-delay: 100ms;
	}

	/* Reduced motion: fade only. */
	@media (prefers-reduced-motion: reduce) {
		@starting-style {
			translate: none;
		}
	}
}
```

The same trick crossfades a layer that appears on a state change (the hero's
A/B switch): `opacity` transition from `@starting-style` in (250 ms), and to
fade back out, keep the layer mounted once it has been shown and transition
`opacity: 0` (200 ms). Mark "has switched" with a `data-*` attribute so the
first page load doesn't fade.

### Stagger in on first scroll

A list below the fold rises in once, 50 ms apart, when its top is 40% up the
viewport. JavaScript only flips the state; CSS does the motion. Lists already
on screen at load are left alone (nothing flashes away on hydration), and
without JavaScript nothing is ever hidden.

```js
// Only for lists that start below the fold.
if (list.getBoundingClientRect().top >= innerHeight) {
	;[...list.children].forEach((child, i) =>
		child.style.setProperty('--stagger-index', i),
	)
	list.dataset.stagger = 'waiting'
	const observer = new IntersectionObserver(
		([entry]) => {
			if (!entry.isIntersecting) return
			list.dataset.stagger = 'in'
			observer.disconnect()
		},
		{ rootMargin: '0px 0px -40% 0px' },
	)
	observer.observe(list)
}
```

```css
@media (prefers-reduced-motion: no-preference) {
	.list[data-stagger='waiting'] > * {
		opacity: 0;
		translate: 0 20px;
	}

	/* `backwards` holds the first frame through the delay and leaves nothing
	   set on the children afterwards. */
	.list[data-stagger='in'] > * {
		animation: stagger-in var(--duration-enter) var(--ease-out) backwards;
		animation-delay: calc(min(var(--stagger-index, 0), 8) * 50ms);
	}
}

@keyframes stagger-in {
	from {
		opacity: 0;
		translate: 0 20px;
	}
}
```

### Scroll-driven parallax

Depth, not spectacle: linear, tied to the scroll, a few percent of its speed.
CSS only, behind `@supports`.

- **Sticky text drifts** from −30 px to 30 px over its section's time in view
  (±20 px for shorter sections). Use the _section's_ timeline: a sticky
  element's own view progress stops while it is stuck.
- **A hero image sinks** up to 30 px behind an edge as it scrolls away, clipped
  on that edge only (`clip-path: inset(-100vh -100vw 0 -100vw)`).

```css
.section {
	view-timeline: --sticky-section block;
}

@supports (animation-timeline: view()) {
	@media (prefers-reduced-motion: no-preference) {
		.section .sticky {
			animation: sticky-drift linear both;
			animation-timeline: --sticky-section;
			animation-range: cover;
		}
	}
}

@keyframes sticky-drift {
	from {
		transform: translateY(-30px);
	}

	to {
		transform: translateY(30px);
	}
}
```

### Marketing demos (terminals)

An explanatory visual can take longer, because it is seen once and shows how
the product works. Our terminals are server-rendered as the complete
transcript (that is also the reduced-motion and no-JavaScript view, and
`aria-hidden`, since the copy says the same). The first time one is 40% in
view it plays once: the screen **boots like a CRT** (a bright 2 px line grows
from the middle out on `--ease-out`, opens vertically on `--ease-in-out`, and
fades like phosphor, 650 ms in all), then each line fades and rises 0.25 rem
at its own `--at` (200 ms, `ease-out`). Anything timed against the session,
like a phone's screenshot changing when a flag goes on, keys off the same
`[data-terminal]` state and `calc(var(--boot) + var(--at))`.

---

## Button

A pill, `label-1` type, themed by `data-theme`. Prefer a real `<button>` (or
`<a>` when it navigates). `primary` is ink, the view's main action; `accent`
(orange, ink text) is for AI actions and send only, one per view.

```html
<button class="button" data-theme="primary">Start testing</button>
<a class="button" data-theme="secondary" href="/login">Login</a>
```

```css
.button {
	display: inline-block;
	padding: 0.4em 1.2em;
	font-size: 14px; /* or: compose .label-1 */
	line-height: 1.4;
	white-space: nowrap;
	border: 1px solid rgb(var(--theme-border-rgb) / 0.2);
	border-radius: var(--border-radius-pill);
}

.button[data-theme='primary'] {
	color: rgb(var(--theme-background-alt-rgb));
	background-color: rgb(var(--theme-foreground-rgb));
}
/* AI actions and send: orange, with ink on it in both themes. */
.button[data-theme='accent'] {
	color: rgb(var(--theme-on-accent-rgb));
	background-color: rgb(var(--theme-accent-rgb));
}
.button[data-theme='secondary'] {
	color: rgb(var(--theme-foreground-rgb));
	background-color: rgb(var(--theme-background-main-rgb));
}
.button[data-theme='transparent'] {
	color: rgb(var(--theme-foreground-rgb));
	background: none;
}

.button:disabled {
	cursor: not-allowed;
	opacity: 0.5;
}
.button[data-loading='true'] {
	cursor: progress;
}

/* Hover only where there's a real pointer, so touch doesn't get stuck states. */
@media (pointer: fine) {
	.button:not(:disabled):hover {
		border-color: rgb(var(--theme-border-rgb) / 0.3);
	}
	.button[data-theme='primary']:not(:disabled):hover {
		background-color: rgb(var(--theme-foreground-rgb) / 0.88);
	}
	.button[data-theme='secondary']:not(:disabled):hover,
	.button[data-theme='transparent']:not(:disabled):hover {
		background-color: rgb(var(--theme-foreground-rgb) / 0.1);
	}
}

.button:not(:disabled):active {
	transform: translateY(1px);
}
```

**States:** `disabled` (dim, `not-allowed`), `data-loading="true"`
(`cursor: progress`, pair with `aria-busy="true"` and a spinner). Never remove
the focus ring — style `:focus-visible` if the default doesn't fit.

### The character roll (signature hover)

Our buttons roll each character up to an identical copy on hover. It's pure
CSS motion over a specific markup shape; JS only splits the text.

**How it works.** Each character sits in a fixed-height cell
(`height: 1.4em; overflow: hidden`) holding two stacked copies of the glyph.
On hover the inner stack translates up exactly one cell (`-1.4em`), so the
second copy lands where the first was. A per-character `--roll-index` staggers
the delay for the cascade. Spaces use a non-breaking space so the cell keeps
its width.

```html
<span class="roll" aria-hidden="true">
	<span class="roll-char" style="--roll-index: 0">
		<span class="roll-inner">
			<span class="roll-face">S</span>
			<span class="roll-face">S</span>
		</span>
	</span>
	<!-- …one .roll-char per character… -->
</span>
<!-- Keep the real, un-split text for assistive tech: -->
<span class="sr-only">Start</span>
```

```css
.roll-char {
	display: inline-block;
	height: 1.4em; /* must equal the line-height, not 1em — or descenders clip */
	overflow: hidden;
	line-height: 1.4em;
	vertical-align: top;
}
.roll-inner {
	display: block;
	transition: transform var(--duration-base) var(--ease-roll);
	transition-delay: calc(var(--roll-index) * 18ms);
}
.roll-face {
	display: block;
	height: 1.4em;
	line-height: 1.4em;
}

@media (pointer: fine) {
	.button:not(:disabled):hover .roll-inner {
		transform: translateY(-1.4em);
	}
}
@media (prefers-reduced-motion: reduce) {
	.roll-inner {
		transition: none;
	}
}
```

**Accessibility.** The split, duplicated glyphs are `aria-hidden`; a
visually-hidden copy of the real label carries the accessible name (so it
isn't read three times). The roll is decorative — it's gone under reduced
motion and on touch, and the button works without it.

---

## Card

A rounded surface on `--theme-background-alt` with a hairline ring, usually
a small label above a heading. No emoji: a card that needs a marker gets a
line icon on its place's tint tile. Keep the markup flat.

```html
<article class="card">
	<p class="label-2">Feature</p>
	<h3 class="heading-4">Continuous delivery</h3>
	<p class="body-3">Release features safely behind a flag.</p>
</article>
```

```css
.card {
	padding: 1.5rem;
	background-color: rgb(var(--theme-background-alt-rgb));
	border-radius: var(--border-radius-medium);
	box-shadow: 0 0 0 1px rgb(var(--theme-hairline-rgb));
}
```

A linked card (`<a class="card">`) gives on press and nudges its "more" link
on hover; see [Press and hover](#press-and-hover). A list of cards below the
fold staggers in ([Stagger in on first scroll](#stagger-in-on-first-scroll)).

---

## Surface / panel

Float rounded panels over the main background instead of flush, hard-bordered
blocks. Borders are low-contrast; lean on radius and background to separate,
not heavy lines.

```css
.surface {
	background-color: rgb(var(--theme-background-alt-rgb));
	border: 1px solid rgb(var(--theme-border-rgb) / 0.35);
	border-radius: var(--border-radius-large);
}
```

---

## Form fields

Real `<input>`, `<textarea>`, `<select>`, each with a `<label>`. Inputs
inherit color and use a transparent background (from `base.css`); give them a
token border and radius. Actions on `--theme-background-actions`.

```css
.field {
	padding: 0.6em 0.9em;
	border: 1px solid rgb(var(--theme-border-rgb) / 0.35);
	border-radius: var(--border-radius-default);
}
.field:focus-visible {
	outline: 2px solid rgb(var(--theme-accent-rgb));
	outline-offset: 1px;
}
```

---

## Disclosure (FAQ, etc.)

Use `<details>/<summary>` — open/closed state, keyboard, and toggling are
native. Style `summary` (we hide the default marker in `base.css`) and animate
with CSS on `[open]`; no JS needed for the basic behavior.

```html
<details class="faq">
	<summary class="label-1">Is there a free trial?</summary>
	<p class="body-3">Yes — no payment method required to start.</p>
</details>
```

Open and close animate the content's height to and from `auto`, with a fade,
through `::details-content` and `interpolate-size`. Closing is quicker than
opening. Browsers without them, and reduced motion, open instantly.

```css
.faq {
	interpolate-size: allow-keywords;
}

@media (prefers-reduced-motion: no-preference) {
	.faq::details-content {
		block-size: 0;
		/* hidden, not clip: it contains the content's margins. */
		overflow: hidden;
		opacity: 0;
		transition:
			block-size 200ms var(--ease-out),
			opacity 150ms ease-out,
			content-visibility 200ms allow-discrete;
	}

	.faq[open]::details-content {
		block-size: auto;
		opacity: 1;
		transition-duration: 280ms, 200ms, 280ms;
	}
}
```

---

_Keep this in sync with `base.css` and `/design.md`. When a real component in a
project diverges from these recipes, update the recipe here so the next
project (or agent) inherits the improvement._
