# Corn UI
> Corn UI is a lightweight, framework-agnostic component library built with vanilla JavaScript and CSS custom properties. It works in any environment — no framework required. React wrapper packages are available by appending `-react` to any package name.
- Docs: https://cornui.com
- Version: 1.0.0-beta.4
- License: MIT
## CDN Quick Start
Load Corn UI without npm or a build tool. All packages expose a global variable on `window`.
Prefer the single entry point: `index.min.js` carries every package behind the
one `CornUI` global, and `index.min.css` every stylesheet. Reach for the
per-package bundles below only when a page needs part of the library.
```html
```
Pin a version in anything you deploy — the `/latest/` path follows the newest
release and will change underneath you. Versioned files never change:
`https://cornui.com/cdn/v1.0.0-beta.4/index.min.js`.
Every bundle has a published sha384 hash, listed at `https://cornui.com/cdn/v1.0.0-beta.4/integrity.json`.
An `integrity` attribute needs `crossorigin` beside it or the check fails
closed:
```html
```
To vendor the library and build offline, download
`https://cornui.com/cdn/v1.0.0-beta.4/corn-ui-v1.0.0-beta.4.tar.gz`, which unpacks to
`corn-ui-v1.0.0-beta.4/dist/` holding every bundle plus `integrity.json`. Each
file is also addressable by name from the GitLab Package Registry, and every
tagged release lists them: https://gitlab.com/corn-dev/corn-ui/-/releases
Per-package bundles:
```html
```
`@corndev/ui-icons` has no CDN bundle — install it from npm.
CDN globals and usage:
```js
// @corndev/ui → window.CornUI
const { createButton, createStack } = CornUI;
const btn = createButton(document.querySelector('#btn'), { variant: 'primary' });
// @corndev/ui-overlays → window.CornUIOverlays
const { createModal, createDrawer, createTooltip } = CornUIOverlays;
const modal = createModal({ title: 'Hello', content: 'World', size: 'md' });
modal.open();
// @corndev/ui-notifications → window.CornUINotifications
const { createToastManager, createAlert } = CornUINotifications;
const toast = createToastManager({ position: 'top-right' });
toast.show({ message: 'Saved!', type: 'success' });
// @corndev/ui-markdown → window.CornUIMarkdown
const { createMarkdown } = CornUIMarkdown;
createMarkdown(document.querySelector('#content'), { content: '# Hello\n\nWorld' });
```
### Latest CDN URLs (unpinned)
Always points to the most recently published release. Use pinned versioned URLs in production.
```
https://cornui.com/cdn/latest/theme.min.css
https://cornui.com/cdn/latest/corn-ui.min.js
https://cornui.com/cdn/latest/corn-ui-overlays.min.css
https://cornui.com/cdn/latest/corn-ui-overlays.min.js
https://cornui.com/cdn/latest/corn-ui-notifications.min.css
https://cornui.com/cdn/latest/corn-ui-notifications.min.js
https://cornui.com/cdn/latest/corn-ui-markdown.min.css
https://cornui.com/cdn/latest/corn-ui-markdown.min.js
```
## npm Installation
Packages are published to npmjs.org under the `@corndev` scope; no registry configuration is needed.
```bash
npm install @corndev/ui
npm install @corndev/ui-overlays
npm install @corndev/ui-notifications
npm install @corndev/ui-markdown
npm install @corndev/ui-icons
npm install @corndev/ui-hooks
npm install @corndev/store
npm install @corndev/query
```
Install every `@corndev` package at the same version; each depends on its siblings at exactly its own.
**Import each package's stylesheet once.** `@corndev/ui/theme` styles only `@corndev/ui`'s components; every other package ships its own, and without it that package's components render as unstyled markup (a development build warns, naming the missing import):
```js
import '@corndev/ui/theme';
import '@corndev/ui-overlays/styles'; // Modal, Drawer, Accordion, Tooltip, Popover, Menu
import '@corndev/ui-notifications/styles'; // Alert, Toast
import '@corndev/ui-markdown/styles'; // Markdown
import '@corndev/ui-icons/styles'; // Icon
```
The `-react` packages ship no CSS; the stylesheet comes from the vanilla package beside each.
## React Packages
Every vanilla package has a React mirror. Add `-react` to any package name:
- `@corndev/ui-react` — React wrappers for `@corndev/ui`. npm install @corndev/ui @corndev/ui-react
- `@corndev/ui-overlays-react` — React wrappers for `@corndev/ui-overlays`. npm install @corndev/ui-overlays @corndev/ui-overlays-react
- `@corndev/ui-notifications-react` — React wrappers for `@corndev/ui-notifications`. npm install @corndev/ui-notifications @corndev/ui-notifications-react
- `@corndev/ui-markdown-react` — React wrappers for `@corndev/ui-markdown`. npm install @corndev/ui-markdown @corndev/ui-markdown-react
- `@corndev/ui-icons-react` — React wrappers for `@corndev/ui-icons`. npm install @corndev/ui-icons-react
React usage:
```jsx
import '@corndev/ui/theme';
import { Button, Stack, Input } from '@corndev/ui-react';
import { Modal, Drawer, Accordion, Tooltip, Popover, Menu } from '@corndev/ui-overlays-react';
import { Alert, NotificationProvider, useNotifications } from '@corndev/ui-notifications-react';
import { Markdown } from '@corndev/ui-markdown-react';
import { SearchIcon } from '@corndev/ui-icons-react';
```
Every React component also accepts `className` (merged with the Corn UI classes) and forwards any remaining props to the underlying DOM element.
NotificationProvider usage (wrap your app once, then call `useNotifications()` anywhere):
```jsx
import { NotificationProvider, useNotifications } from '@corndev/ui-notifications-react';
function App() {
return (
);
}
function SaveButton() {
const { show } = useNotifications();
return ;
}
```
## Design System
- **Colors:** Purple primary (`--corn-primary`), Indigo secondary (`--corn-secondary`)
- **Spacing:** 4px base — `--corn-spacing-{xs|sm|md|lg|xl}`
- **Typography:** 14px, 1.4 line-height, system font stack
- **Radius:** `--corn-radius-sm` 4px / `--corn-radius-md` 6px / `--corn-radius-lg` 8px
- **Dark mode:** Manual toggle via `data-theme="dark"` on ``
- **BEM naming:** All CSS classes use `.corn-*` prefix
## Theming
Corn UI is themed entirely through CSS custom properties. There is no theme
object, no build step, and no JS API — you redefine tokens in your own CSS and
every component follows. Component CSS never hardcodes a colour, so you never
need `!important` or selector overrides.
Load the theme once, then override the tokens you care about:
```css
@import '@corndev/ui/theme';
:root {
--corn-primary: #0ea5e9;
--corn-primary-hover: #38bdf8;
--corn-primary-active: #0284c7;
}
```
That is a complete re-theme. Focus rings, overlays, and shadows are **derived**
from the brand tokens with `color-mix()`, so they track `--corn-primary` and
`--corn-danger` automatically — you do not restate them. Override a derived
token directly only if you want it to stop following its source:
```css
:root {
--corn-focus-ring-width: 2px; /* thinner ring, same hue */
--corn-focus-ring: 0 0 0 2px rgba(0, 0, 0, 0.4); /* opt out of the hue entirely */
}
```
Tokens are ordinary custom properties, so they cascade — scope a theme to part
of the page by setting them on any element:
```css
.checkout-panel { --corn-primary: #16a34a; }
```
### Dark mode
Set `data-theme="dark"` on ``. The bundled dark theme overrides 40 tokens.
Because the derived tokens are composed rather than copied, focus rings re-tint
for dark mode with no extra declarations.
```js
document.documentElement.setAttribute('data-theme', 'dark');
```
The dark theme ships on `:root[data-theme='dark']`, which outranks a plain `:root` on
purpose: without the leading `:root` the two selectors tie on specificity, and
since app CSS loads after the library, a `:root` re-theme would silently win in
dark mode too and take dark mode down with it. So a `:root` block sets the
**light** theme; to change a dark value, match the same selector:
```css
/* light */
:root { --corn-primary: #0ea5e9; }
/* dark */
:root[data-theme='dark'] { --corn-primary: #38bdf8; }
```
A bare `[data-theme='dark'] { … }` scores lower and will not apply. Tokens the
dark theme does not restate are derived and follow the `:root` override in both.
`prefers-color-scheme` is not wired up — the attribute is the only switch. Track
the OS setting by flipping it:
```js
const mq = matchMedia('(prefers-color-scheme: dark)');
const sync = () => document.documentElement.setAttribute('data-theme', mq.matches ? 'dark' : 'light');
sync();
mq.addEventListener('change', sync);
```
### Browser support
Derived tokens use `color-mix()` (Chrome 111, Safari 16.2, Firefox 113 — all
2023). Older browsers fall back to the default purple/red rings, which stay
visible but stop tracking your brand colour.
## Design Tokens
Every token, read from `src/theme/tokens.css`. All of them are overridable.
### COLORS — Primary & Secondary
| Token | Default |
|---|---|
| `--corn-primary` | `#7c3aed` |
| `--corn-primary-hover` | `#6d28d9` |
| `--corn-primary-active` | `#5b21b6` |
| `--corn-primary-light` | `#ede9fe` |
| `--corn-secondary` | `#4f46e5` |
| `--corn-secondary-hover` | `#4338ca` |
| `--corn-secondary-active` | `#3730a3` |
| `--corn-secondary-light` | `#e0e7ff` |
### COLORS — Status Colors
| Token | Default |
|---|---|
| `--corn-success` | `#047857` |
| `--corn-success-light` | `#d1fae5` |
| `--corn-warning` | `#b45309` |
| `--corn-warning-light` | `#fef3c7` |
| `--corn-danger` | `#b91c1c` |
| `--corn-danger-light` | `#fee2e2` |
| `--corn-info` | `#1d4ed8` |
| `--corn-info-light` | `#dbeafe` |
### COLORS — Semantic Text Colors
| Token | Default |
|---|---|
| `--corn-text-primary` | `#1a1a1a` |
| `--corn-text-secondary` | `#4d4d4d` |
| `--corn-text-tertiary` | `#6b6b6b` |
| `--corn-text-inverse` | `#ffffff` |
### COLORS — Semantic Background Colors
| Token | Default |
|---|---|
| `--corn-bg-primary` | `#ffffff` |
| `--corn-bg-secondary` | `#f9f9f9` |
| `--corn-bg-tertiary` | `#f0f0f0` |
### COLORS — Border Colors
| Token | Default |
|---|---|
| `--corn-border-light` | `#e5e5e5` |
| `--corn-border-primary` | `#d0d0d0` |
| `--corn-border-secondary` | `#a0a0a0` |
### COLORS — Chart / Data Viz
| Token | Default |
|---|---|
| `--corn-chart-1` | `#4a3aa7` |
| `--corn-chart-2` | `#eb6834` |
| `--corn-chart-3` | `#1baf7a` |
| `--corn-chart-4` | `#eda100` |
| `--corn-chart-5` | `#e87ba4` |
| `--corn-chart-6` | `#008300` |
| `--corn-chart-7` | `#2a78d6` |
| `--corn-chart-8` | `#e34948` |
| `--corn-chart-seq-1` | `#b8a5fc` |
| `--corn-chart-seq-2` | `#9575f8` |
| `--corn-chart-seq-3` | `#7c3aed` |
| `--corn-chart-seq-4` | `#5b21b6` |
### SPACING
| Token | Default |
|---|---|
| `--corn-spacing-xs` | `4px` |
| `--corn-spacing-sm` | `8px` |
| `--corn-spacing-md` | `16px` |
| `--corn-spacing-lg` | `24px` |
| `--corn-spacing-xl` | `32px` |
### TYPOGRAPHY
| Token | Default |
|---|---|
| `--corn-font-family` | `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif` |
| `--corn-font-family-mono` | `'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace` |
| `--corn-font-size-xs` | `11px` |
| `--corn-font-size-sm` | `12px` |
| `--corn-font-size-base` | `14px` |
| `--corn-font-size-lg` | `16px` |
| `--corn-font-size-xl` | `18px` |
| `--corn-font-weight-regular` | `400` |
| `--corn-font-weight-medium` | `500` |
| `--corn-font-weight-bold` | `700` |
| `--corn-line-height` | `1.4` |
| `--corn-control-height-sm` | `calc(var(--corn-font-size-sm) * var(--corn-line-height) + 10px)` |
| `--corn-control-height-md` | `calc(var(--corn-font-size-base) * var(--corn-line-height) + 14px)` |
| `--corn-control-height-lg` | `calc(var(--corn-font-size-lg) * var(--corn-line-height) + 22px)` |
### BORDER RADIUS
| Token | Default |
|---|---|
| `--corn-radius-sm` | `4px` |
| `--corn-radius-md` | `6px` |
| `--corn-radius-lg` | `8px` |
### SHADOWS
| Token | Default |
|---|---|
| `--corn-shadow-sm` | `0 1px 2px rgba(0, 0, 0, 0.05)` |
| `--corn-shadow-md` | `0 4px 6px rgba(0, 0, 0, 0.1)` |
| `--corn-shadow-lg` | `0 10px 15px rgba(0, 0, 0, 0.1)` |
### TRANSITIONS
| Token | Default |
|---|---|
| `--corn-transition-fast` | `100ms ease-in-out` |
| `--corn-transition-base` | `200ms ease-in-out` |
| `--corn-transition-slow` | `300ms ease-in-out` |
### BREAKPOINTS
| Token | Default |
|---|---|
| `--corn-bp-sm` | `640px` |
| `--corn-bp-md` | `960px` |
| `--corn-bp-lg` | `1280px` |
### SEMANTIC / DERIVED
| Token | Default |
|---|---|
| `--corn-focus-ring-width` | `3px` |
| `--corn-focus-ring-color` | `rgba(124, 58, 237, 0.15)` |
| `--corn-focus-ring-error-color` | `rgba(185, 28, 28, 0.15)` |
| `--corn-focus-ring` | `0 0 0 var(--corn-focus-ring-width) var(--corn-focus-ring-color)` |
| `--corn-focus-ring-error` | `0 0 0 var(--corn-focus-ring-width) var(--corn-focus-ring-error-color)` |
| `--corn-backdrop` | `rgba(0, 0, 0, 0.5)` |
| `--corn-shadow-color` | `rgba(0, 0, 0, 0.15)` |
| `--corn-chart-grid` | `var(--corn-border-light)` |
| `--corn-chart-axis` | `var(--corn-border-primary)` |
| `--corn-layout-divider` | `var(--corn-border-primary)` |
| `--corn-chart-surface` | `var(--corn-bg-primary)` |
| `--corn-chart-muted` | `var(--corn-text-tertiary)` |
## Component API Reference
All vanilla `create*` functions return a controller object. Shared interface:
- `update(options)` — apply new options without re-mounting
- `destroy()` — remove all Corn UI classes and event listeners
Components marked **CSS-only** have no JS controller — apply their classes directly.
## @corndev/ui
Core vanilla JS layout and form components with CSS design tokens.
**Install:** `npm install @corndev/ui`
**React package:** `@corndev/ui-react`
**CDN JS:** `https://cornui.com/cdn/v1.0.0-beta.4/corn-ui.min.js` (global: `CornUI`)
**CDN CSS:** `https://cornui.com/cdn/v1.0.0-beta.4/theme.min.css`
### Components
### createTreeState
A tree's state with no DOM and no framework: which nodes are expanded, which one is selected, and a hook for fetching a branch's children when it opens. The part of a tree every consumer writes identically — the renderer, with its icons and context menus, stays with the consumer.
**JavaScript** — this is the API: `createTreeState(options)` → `TreeState`. It renders nothing.
**Options:**
| Option | Type | Default | Description |
|---|---|---|---|
| `defaultExpanded` | `Iterable` | `undefined` | Nodes open to begin with (uncontrolled). |
| `defaultSelected` | `string\|null` | `undefined` | Node selected to begin with (uncontrolled). |
| `onExpand` | `(value: string) => void\|Promise` | `undefined` | Called when a node opens, once per open. It may be async: a hierarchy that is fetched as it is explored — a filesystem, an API, a large table — is the common case, not the exotic one, and walking it up front is what makes a file tree do tens of thousands of round trips it did not need to. The node stays open while the promise is pending, and stays open if it rejects: a branch that collapsed itself under the cursor would lose the user's place, and the caller knows what to show instead. A rejection is passed to `onError` rather than swallowed. Same contract as `createNavigation`'s `onExpand(item)`, so one "get these children" function serves both. |
| `onCollapse` | `(value: string) => void` | `undefined` | Called when a node closes. |
| `onSelect` | `(value: string\|null) => void` | `undefined` | Called when the selection changes — *changes*, not "when select() is called". Selecting the node that is already selected does nothing. |
| `onError` | `(error: unknown, value: string) => void` | `undefined` | Called when `onExpand` fails — whether it threw synchronously or returned a promise that rejected. The two behave the same on purpose: a caller who wrote a non-async `onExpand` has no reason to expect a throw to escape `toggle()` into their event handler. |
**Note:** `onExpand` may be async, because a hierarchy fetched as it is explored — a filesystem, an API, a large table — is the common case. The node opens immediately and stays open while the promise is pending, and stays open if it rejects, with the error going to `onError`; a branch that collapsed itself under the cursor would lose the user's place. It is the same callback contract as `createNavigation`'s `onExpand(item)`, so one "get these children" function serves both. Selecting the node that is already selected does nothing and fires nothing, since consumers act on a selection by loading what was selected. `getState()` returns the same object until something changes, which is what React's useSyncExternalStore requires. `useTree` in @corndev/ui-hooks binds it to a component and adds nothing.
### Button
Interactive button element.
**HTML:**
```html
```
**Classes:**
| Class |
|---|
| `.corn-button` |
**Attributes:**
| Attribute | Values |
|---|---|
| `data-size` | `sm\|md\|lg` |
| `data-variant` | `icon\|unstyled` |
| `data-level` | `primary\|secondary\|success\|danger\|warning\|ghost\|neutral` |
| `data-emphasis` | `solid\|soft\|outline\|quiet` |
| `data-full-width` | `present or absent` |
| `data-loading` | `present or absent` |
**JavaScript** — optional: `createButton(el, options)` → `{ update: (opts: ButtonOptions) => void, destroy: () => void }`. Only adds and removes the classes and attributes above, so the HTML is equivalent. Use it to change options at runtime.
**Options** — the React props are the same:
| Option | Type | Default | Description |
|---|---|---|---|
| `variant` | `'button'\|'icon'\|'unstyled'` | `'button'` | What kind of control this is. `icon` makes it square and drops the label padding, for a control whose whole content is an icon. Independent of `level`. `unstyled` has no appearance at all — for making a whole card or row clickable — and ignores `level` and `emphasis`, since it has no fill. |
| `level` | `'primary'\|'secondary'\|'success'\|'danger'\|'warning'\|'neutral'\|'ghost'` | `'primary'` | Which colour. `ghost` is deprecated — it is the one value that names a fill rather than a colour, and it means `level='primary'` with `emphasis='outline'`. `neutral` has no hue: the text's own colour, for an action that should not read as a status or the accent — with `emphasis: 'quiet'`, a plain grey hover on text-coloured ink. |
| `emphasis` | `'solid'\|'soft'\|'outline'\|'quiet'` | `'solid'` | How much of that colour is filled in, independently of which colour it is. `solid` is a filled block, `soft` a tint with the colour as the text, `outline` a border and no fill, `quiet` neither — for a destructive action in a table row that should not shout. Hover escalates one rung; the hit area is the same at every rung. |
| `size` | `'sm'\|'md'\|'lg'` | `'md'` | |
| `disabled` | `boolean` | `false` | |
| `loading` | `boolean` | `false` | |
| `fullWidth` | `boolean` | `false` | Fill the row. An unstyled button becomes a block, for a list entry or a whole card that is one control. |
| `type` | `'button'\|'submit'\|'reset'` | `'button'` | |
**React:** `Button` — from `@corndev/ui-react`
**Note:** `data-variant` and `data-level` are separate dimensions on purpose: the variant says what kind of control this is (`button` or `icon`), and the level carries the colour ladder, so a red delete icon is expressible as `variant=icon level=danger`. An icon button is square by construction and sized by hit area rather than by its glyph — the control height for its size, `--corn-control-height-{sm,md,lg}` (26.8/33.6/44.4px by default), the same as a text button and an input, and all clear of the 24px minimum in WCAG 2.5.8. It needs an `aria-label`: an icon-only button with no accessible name is invisible to a screen reader, and `createButton` warns when one is missing. **`data-level` is the colour and `data-emphasis` is the fill**, and they are independent: `solid` (the default) is a filled block, `soft` a tint with the colour as the text, `outline` a border and no fill, `quiet` neither. That split is what makes a quiet red Delete expressible — before it, `danger` was always a solid block and the only borderless value, `ghost`, was hardcoded to primary. Hover escalates one rung, and the padding is identical at every rung so a quiet button still meets the 24px target. **`level="ghost"` is deprecated**: it is the one value of `level` that names a fill rather than a colour, and it means `level="primary" emphasis="outline"`. It still renders, so existing markup is unaffected. **`variant="unstyled"`** removes the appearance entirely, for making a whole card or row clickable and keyboard-reachable; it has no fill, so `level` and `emphasis` are not emitted, while the focus ring, disabled and loading states and a 24px minimum target remain.
### Stack
Flex stack container — vertical or horizontal.
**HTML:**
```html
First
Second
```
**Classes:**
| Class |
|---|
| `.corn-stack` |
**Attributes:**
| Attribute | Values |
|---|---|
| `data-direction` | `vertical\|horizontal` |
| `data-gap` | `xs\|sm\|md\|lg\|xl` |
| `data-align` | `start\|center\|end\|stretch` |
| `data-justify` | `start\|center\|end\|between\|around` |
**JavaScript** — optional: `createStack(el, options)` → `{ update: (opts: StackOptions) => void, destroy: () => void }`. Only adds and removes the classes and attributes above, so the HTML is equivalent. Use it to change options at runtime.
**Options** — the React props are the same:
| Option | Type | Default |
|---|---|---|
| `direction` | `'vertical'\|'horizontal'` | `'vertical'` |
| `gap` | `'xs'\|'sm'\|'md'\|'lg'\|'xl'` | `'md'` |
| `align` | `'start'\|'center'\|'end'\|'stretch'` | `'stretch'` |
| `justify` | `'start'\|'center'\|'end'\|'between'\|'around'` | `'start'` |
**React:** `Stack` — from `@corndev/ui-react`
### Group
Horizontal flex group for inline elements.
**HTML:**
```html
```
**Classes:**
| Class |
|---|
| `.corn-group` |
**Attributes:**
| Attribute | Values |
|---|---|
| `data-nowrap` | `present or absent` |
| `data-gap` | `xs\|sm\|md\|lg\|xl` |
| `data-align` | `start\|center\|end\|baseline` |
| `data-justify` | `start\|center\|end\|between\|around` |
| `data-grow` | `present or absent` |
**JavaScript** — optional: `createGroup(el, options)` → `{ update: (opts: GroupOptions) => void, destroy: () => void }`. Only adds and removes the classes and attributes above, so the HTML is equivalent. Use it to change options at runtime.
**Options** — the React props are the same:
| Option | Type | Default | Description |
|---|---|---|---|
| `gap` | `'xs'\|'sm'\|'md'\|'lg'\|'xl'` | `'sm'` | |
| `align` | `'start'\|'center'\|'end'\|'baseline'` | `'center'` | |
| `justify` | `'start'\|'center'\|'end'\|'between'\|'around'` | `'start'` | |
| `wrap` | `boolean` | `true` | |
| `grow` | `boolean` | `false` | Every child takes an equal share of the row, whatever it contains. A child's own `flex` always wins — the rule is 0-0-0, so even a single class like `.no-grow { flex: none }` keeps that child at its natural width. With `wrap`, a child that no longer fits moves to the next line and fills it; with `wrap={false}` the children shrink instead. |
**React:** `Group` — from `@corndev/ui-react`
### Spacing
Margin and padding utility classes on the spacing tokens.
**CSS-only** — no `create*` function and no React wrapper; the classes are the whole API.
**HTML:**
```html
```
**Classes:**
| Class |
|---|
| `.corn-container` |
**Attributes:**
| Attribute | Values |
|---|---|
| `data-size` | `sm\|md\|lg\|fluid` |
**JavaScript** — optional: `createContainer(el, options)` → `{ update: (opts: ContainerOptions) => void, destroy: () => void }`. Only adds and removes the classes and attributes above, so the HTML is equivalent. Use it to change options at runtime.
**Vanilla options** — the React props differ, see below:
| Option | Type | Default |
|---|---|---|
| `size` | `'sm'\|'md'\|'lg'\|'fluid'` | `'md'` |
**React:** `Container` — from `@corndev/ui-react`
**React props:**
| Prop | Type | Default |
|---|---|---|
| `size` | `'sm'\|'md'\|'lg'\|'fluid'` | `'md'` |
| `as` | `React.ElementType` | `'div'` |
| `children` | `React.ReactNode` | `undefined` |
| `className` | `string` | `undefined` |
### Grid
CSS grid container with column and gap utilities.
**HTML:**
```html
Column 1
Column 2
Column 3
Location
Name
Format
```
**Classes:**
| Class |
|---|
| `.corn-grid` |
| `.corn-grid-col` |
**Attributes:**
| On | Attribute | Values |
|---|---|---|
| `.corn-grid` | `data-cols` | `present or absent` |
| `.corn-grid` | `data-gap` | `xs\|sm\|md\|lg\|xl` |
| `.corn-grid` | `data-row-gap` | `xs\|sm\|md\|lg\|xl` |
| `.corn-grid` | `data-col-gap` | `xs\|sm\|md\|lg\|xl` |
| `.corn-grid-col` | `data-span` | `1\|2\|3\|4\|5\|6\|7\|8\|9\|10\|11\|12\|content\|auto` |
**CSS variables:**
| Variable | Default | Description |
|---|---|---|
| `--corn-grid-cols` | `12` | Column count when `data-cols` is set |
**JavaScript** — optional: `createGrid(el, options)` → `{ update: (opts: GridOptions) => void, destroy: () => void }`. Only adds and removes the classes and attributes above, so the HTML is equivalent. Use it to change options at runtime.
**Options** — the React props are the same:
| Option | Type | Default | Description |
|---|---|---|---|
| `cols` | `number\|null` | `12` | Equal columns, written to the `--corn-grid-cols` custom property. Pass `null` for no column template, so you can set `grid-template-columns` or `grid-template-areas` yourself. |
| `gap` | `'xs'\|'sm'\|'md'\|'lg'\|'xl'\|null` | `'md'` | Uniform gap between cells. |
| `rowGap` | `'xs'\|'sm'\|'md'\|'lg'\|'xl'` | `undefined` | Row gap only; overrides the uniform gap. |
| `colGap` | `'xs'\|'sm'\|'md'\|'lg'\|'xl'` | `undefined` | Column gap only; overrides the uniform gap. |
**React:** `Grid`, `GridCol` — from `@corndev/ui-react`
**Note:** **`Grid.Col` says how much of the row a child takes**: `span` of 1–12 (of `cols`), `content` for as wide as what is inside it, or `auto` for whatever the row has left. A grid holding columns lays out as a wrapping flex row, because `content` and `auto` cannot be said with fixed tracks; the spans subtract the gaps they do not straddle, so 6 + 6 and 4 + 8 sum exactly to the row. A child that is not a column still renders, sized to its content — nothing is dropped. Below 640px every column takes the full row. In plain markup it is `.corn-grid-col` with `data-span`. The column count is a custom property, not a class, so the controller and the React `cols` prop own `--corn-grid-cols` and overwrite an inline one. Pass `cols: null` to opt out entirely and set `grid-template-columns` or `grid-template-areas` yourself. An invalid count falls back to the stylesheet default of 12 rather than emitting `repeat(0, …)` and voiding the template.
**`` props** — A cell that says how much of the row it takes.
| Prop | Type | Default | Description |
|---|---|---|---|
| `span` | `number\|'content'\|'auto'` | `12` | How much of the row to take. |
| `children` | `React.ReactNode` | `undefined` | |
| `className` | `string` | `undefined` | |
| `style` | `React.CSSProperties` | `undefined` | |
### Collapse
Animated show/hide of a region; hidden content leaves the tab order.
**HTML:**
```html
…
```
**Classes:**
| Class |
|---|
| `.corn-collapse` |
| `.corn-collapse-inner` |
**Attributes:**
| Attribute | Values |
|---|---|
| `data-open` | `present or absent` |
**JavaScript** — optional: `createCollapse(el, options)` → `{ update: (opts: CollapseOptions) => void, open: () => void, close: () => void, toggle: () => void, destroy: () => void }`. Only adds and removes the classes and attributes above, so the HTML is equivalent. Use it to change options at runtime.
**Options** — the React props are the same:
| Option | Type | Default | Description |
|---|---|---|---|
| `open` | `boolean` | `false` | Whether the region is shown. Opening animates it to its natural height; closing animates it shut and then hides it from the tab order and assistive technology. |
**React:** `Collapse` — from `@corndev/ui-react`
**Note:** **Height animates without measuring**: the region is a one-row grid going from `0fr` to `1fr`, so it reaches the content's natural height and follows content that changes size while open. **Closed is `visibility: hidden`** once the transition ends, so hidden controls leave the tab order and the accessibility tree; reduced motion makes the change instant through the transition tokens. The toggle button, with its `aria-expanded` and `aria-controls`, is yours. In React, pass a function as children to build the region only when it first opens; it stays built so closing can animate. Without an animation, `{open && }` is simpler.
### Breadcrumbs
Where the current page sits in a hierarchy, with the middle collapsed when the path is long.
**HTML:**
```html
```
**Classes:**
| Class |
|---|
| `.corn-breadcrumbs` |
| `.corn-breadcrumbs-item` |
| `.corn-breadcrumbs-link` |
| `.corn-breadcrumbs-ellipsis` |
| `.corn-breadcrumbs-current` |
**JavaScript** — required for behaviour: `createBreadcrumbs(el, options)` → `{ update: (opts: Partial) => void, destroy: () => void }`. Adds builds the trail from data and makes the collapsed middle (`maxItems`) expand on click.
**Options** — the React props are the same:
| Option | Type | Default | Description |
|---|---|---|---|
| `items` | `BreadcrumbItem[]` | `required` | The path, the current page last. Every crumb but the last is a link when it has an `href`; the last is always the current page, marked `aria-current="page"`. |
| `maxItems` | `number` | `undefined` | Show at most this many crumbs. The middle ones collapse behind a "…" button that shows them; the first crumb and the current page always stay. |
| `separator` | `string` | `undefined` | Drawn between crumbs, `/` by default. Hidden from assistive technology. |
| `label` | `string` | `'Breadcrumb'` | The navigation landmark's name. |
**React:** `Breadcrumbs` — from `@corndev/ui-react`
**Note:** **An ordered list in a labelled `