This is the canonical reference for the patterns that go into an EDS 2.0 component. Harness-specific scaffolding commands (/new-component in Claude Code, the new-component prompt in Copilot, the eds-component sub-agent in OpenCode) read this guide rather than restating its content.
For the project-wide conventions (file structure, code style, CSS layering, testing, accessibility, conventional commits), see AGENTS.md. This guide adds the component-building specifics that are not covered there.
Before building anything, check documentation/AI-COMPONENT-INDEX.md — a generated, CI-verified list of every existing /next component with its props and sub-components. The component you are about to build may already exist, or an existing one may compose into what you need. One file read replaces walking the whole /next tree.
- Foundation Data-Attribute Reference
- Critical Patterns
- File Templates
- Real-World References
- Common Mistakes
- Advanced Patterns
- Anti-patterns Checklist
- Implementation Status Report
Foundation data-* attributes drive the dynamic token system. Components should set these directly from values in the Figma design — do not invent new attribute values.
| Attribute | Purpose | Valid values |
|---|---|---|
data-color-appearance |
Color theming | neutral, accent, info, success, warning, danger |
data-selectable-space |
Spacing scale | xs, sm, md, lg, xl |
data-space-proportions |
Padding proportions | squished, squared, stretched |
data-baseline |
Text baseline trim | center, grid |
data-font-family |
Typography family | ui, header |
data-font-size |
Font size | xs, sm, md, lg, xl, 2xl, 3xl, 4xl, 5xl, 6xl |
data-line-height |
Line height mode | squished, default |
data-variant |
Component-specific | e.g. primary, secondary, ghost — only when the component |
| has a visual variant axis like Button |
Foundation values are reused across components. Component-specific custom types (like ButtonVariant) should only be introduced when the design has a visual variant axis not covered by foundation attributes.
See also TOKEN_SYSTEM_GUIDE.md for the underlying token system.
Apply data-color-appearance to the smallest element that needs that color, not the component root. Otherwise hover/focus states will inherit the appearance and look wrong.
// ✅ CORRECT: only the icon is accent in Figma
<button>
<span data-color-appearance="accent">
<Icon data={settings} />
</span>
Settings
</button>
// ❌ WRONG: the whole button gets accent — hover will be accent too
<button data-color-appearance="accent">
<Icon data={settings} />
Settings
</button>Why it works: dynamic tokens like --eds-color-bg-fill-emphasis-default resolve against the nearest ancestor's data-color-appearance. Scoping it to the icon span means the button background can still resolve neutrally on hover.
Without data-color-appearance, dynamic tokens won't resolve to the correct color.
Compare horizontal vs vertical padding tokens from figma_get_variable_defs:
- horizontal = vertical →
squared - horizontal > vertical →
stretched - horizontal < vertical →
squished
Example from figma_get_variable_defs:
{
"var(--eds-selectable-space-horizontal)": "16",
"var(--eds-selectable-space-vertical)": "16"
}→ 16 = 16, so use data-space-proportions="squared".
Never copy this attribute from a similar component without verifying — designs differ.
A data-color-appearance attribute alone has no effect — the element also needs a color (or background-color) declaration using a dynamic token, so the token has somewhere to resolve.
/* ✅ CORRECT: dynamic token responds to data-color-appearance */
.eds-component .icon {
display: flex;
color: var(--eds-color-bg-fill-emphasis-default);
}/* ❌ WRONG: opacity dims everything (text, borders, background) at once */
.eds-component[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}Figma specifies exact disabled color tokens. Use them.
// If icon is accent when enabled, change to neutral when disabled
{
icon && (
<span
className="eds-component__icon"
data-color-appearance={disabled ? 'neutral' : 'accent'}
>
{icon}
</span>
)
}.eds-component[data-disabled] {
cursor: not-allowed;
}
.eds-component[data-disabled] .eds-component__text {
color: var(--eds-color-text-disabled);
}
.eds-component[data-disabled] .eds-component__icon {
color: var(--eds-color-text-disabled);
}How to find the tokens: check figma_get_variable_defs for the disabled state, or inspect the disabled variant in get_design_context. Look for bg-disabled, border-disabled, and text-disabled tokens.
Without data-baseline, the text element uses full line-height — so a component Figma shows as 44 px tall will render taller in code:
Padding 16 px top + line-height 20 px + padding 16 px bottom = 52 px ❌
Adding data-baseline="center" (or "grid") enables text-box: trim-both ex alphabetic, which strips line-height overhead so the text occupies only its cap/x-height:
Padding 16 px top + trimmed text ~12 px + padding 16 px bottom = 44 px ✓
<span
data-font-family="ui"
data-font-size="md"
data-line-height="default"
data-baseline="center"
>
Menu Item
</span>When to use which:
data-baseline="center"— interactive elements (buttons, menu items): optically centred textdata-baseline="grid"— body text aligned to the 4 px baseline grid- no
data-baseline— full line-height box (rare in EDS 2.0)
Sub-pixel rounding (43.99 px → 44 px): if pixel-perfect height matters, add min-height while keeping the padding-driven layout:
.eds-component {
min-height: var(--eds-sizing-selectable-lg);
padding-block: var(--eds-selectable-space-vertical);
padding-inline: var(--eds-selectable-space-horizontal);
display: flex;
align-items: center;
}See Checkbox/checkbox.css for a fixed-height example, and packages/eds-tokens/build/css/typography.css for the text-box-trim implementation.
These templates are starting points — replace example tokens with the exact ones from figma_get_variable_defs. Throughout, ComponentName stands for the new component's name (e.g. Avatar); the CSS file uses the lowercase form (avatar.css).
export { ComponentName } from './ComponentName'
export type { ComponentNameProps } from './ComponentName.types'import type { HTMLAttributes } from 'react'
/**
* ComponentName props.
*
* Most components use foundation data-attribute values directly.
* Only define component-specific types if needed (e.g. ButtonVariant for Button).
*/
export type ComponentNameProps = {
/** Disabled state */
disabled?: boolean
// Add component-specific props here
} & HTMLAttributes<HTMLDivElement>Use foundation data-* values directly (see the reference table) unless the component has a true variant axis like Button's primary | secondary | ghost.
Use the forwardRef pattern with a named function (matches Button, Checkbox, Input). Set foundation data-* attributes from the Figma design. Do not import CSS in the component file — CSS is imported globally via next/index.css.
import { forwardRef } from 'react'
import type { ComponentNameProps } from './ComponentName.types'
export const ComponentName = forwardRef<HTMLDivElement, ComponentNameProps>(
function ComponentName({ className, disabled, ...rest }, ref) {
const classes = ['eds-componentname', className].filter(Boolean).join(' ')
// lowercase class name: eds-avatar, not eds-Avatar
return (
<div
ref={ref}
className={classes}
// Verify each attribute in Figma. See § Critical Patterns above.
data-color-appearance={/* from Figma — check default AND disabled states */}
data-selectable-space={/* from Figma */}
data-space-proportions={/* horizontal vs vertical padding */}
data-baseline={/* from Figma */}
data-font-family={/* from Figma */}
data-font-size={/* from Figma */}
data-line-height={/* from Figma */}
data-disabled={disabled || undefined}
{...rest}
/>
)
},
)
ComponentName.displayName = 'ComponentName'Use @layer eds-components and data-attribute selectors. Use EXACT --eds-* tokens from figma_get_variable_defs — never hardcode hex or pixel values.
@layer eds-components {
.eds-componentname {
display: inline-flex;
align-items: center;
gap: var(--eds-typography-gap-horizontal);
background-color: var(--eds-color-bg-fill-emphasis-default);
color: var(--eds-color-text-strong-on-emphasis);
outline: var(--eds-sizing-stroke-thin) solid var(--eds-color-border-strong);
outline-offset: calc(-1 * var(--eds-sizing-stroke-thin));
border-radius: var(--eds-spacing-border-radius-rounded);
padding-block: var(--eds-selectable-space-vertical);
padding-inline: var(--eds-selectable-space-horizontal);
}
.eds-componentname:hover:not(:disabled) {
background-color: var(--eds-color-bg-fill-emphasis-hover);
}
.eds-componentname:focus-visible {
box-shadow:
0 0 0 var(--eds-sizing-stroke-thin) var(--eds-color-bg-canvas),
0 0 0 calc(var(--eds-sizing-stroke-thin) * 2)
var(--eds-color-border-focus);
}
.eds-componentname[data-disabled] {
cursor: not-allowed;
}
& .icon {
display: flex;
/* Dynamic token responds to data-color-appearance — must be set */
color: var(--eds-color-bg-fill-emphasis-default);
}
}Only create this file if a Figma URL was provided.
import figma from '@figma/code-connect'
import { ComponentName } from '.'
figma.connect(ComponentName, 'FIGMA_URL_HERE', {
props: {
variant: figma.enum('Variant', {
Primary: 'primary',
Secondary: 'secondary',
}),
disabled: figma.enum('State', { Disabled: true }),
},
example: ({ variant, disabled }) => (
<ComponentName variant={variant} disabled={disabled} />
),
})import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { axe } from 'jest-axe'
import { ComponentName } from '.'
describe('ComponentName (next)', () => {
describe('Rendering', () => {
it('renders with default props', () => {
render(<ComponentName data-testid="eds-componentname" />)
expect(screen.getByTestId('eds-componentname')).toBeInTheDocument()
})
it('applies custom className', () => {
render(<ComponentName data-testid="eds-componentname" className="custom" />)
expect(screen.getByTestId('eds-componentname')).toHaveClass(
'eds-componentname',
'custom',
)
})
it('forwards ref', () => {
const ref = { current: null as HTMLDivElement | null }
render(<ComponentName ref={ref} />)
expect(ref.current).toBeInstanceOf(HTMLDivElement)
})
it('spreads additional props', () => {
render(<ComponentName data-testid="test" data-custom="value" />)
expect(screen.getByTestId('test')).toHaveAttribute('data-custom', 'value')
})
})
describe('Accessibility', () => {
it('has no accessibility violations', async () => {
const { container } = render(<ComponentName />)
expect(await axe(container)).toHaveNoViolations()
})
})
})import type { Meta, StoryFn } from '@storybook/react-vite'
import { ComponentName, type ComponentNameProps } from '.'
const meta: Meta<typeof ComponentName> = {
title: 'EDS 2.0 (beta)/ComponentName',
component: ComponentName,
tags: ['beta'],
parameters: {
docs: {
description: {
component: `
⚠️ **Beta Component** — this component is under active development.
\`\`\`tsx
import { ComponentName } from '@equinor/eds-core-react/next'
\`\`\`
`,
},
},
},
}
export default meta
export const Introduction: StoryFn<ComponentNameProps> = (args) => {
return <ComponentName {...args} />
}Add the component to packages/eds-core-react/src/components/next/index.ts:
export { ComponentName } from './ComponentName'
export type { ComponentNameProps } from './ComponentName'Add the CSS to packages/eds-core-react/src/components/next/index.css:
@import './ComponentName/componentname.css';Read the source — it's the reference, not a snapshot in this file.
packages/eds-core-react/src/components/next/Button/— variants, color-appearance disabled fallback, full foundation attributespackages/eds-core-react/src/components/next/Input/— container sets the color context for childrenpackages/eds-core-react/src/components/next/Icon/— size inheritance via parent'sdata-font-size
Missing data-color-appearance on an icon that's accent in Figma. The icon renders with no color context, so the dynamic token resolves to whatever the parent provides (likely wrong).
data-color-appearance placed on the whole button. Hover/focus states inherit the appearance and the button reads as accent everywhere, not just on the icon. Move the attribute to the icon span.
Using static tokens instead of dynamic ones.
/* ❌ Only works for the accent theme */
.eds-component {
background: var(--eds-color-bg-accent-fill-emphasis-default);
}
/* ✅ Adapts to data-color-appearance */
.eds-component {
background: var(--eds-color-bg-fill-emphasis-default);
}Hardcoded pixel values.
/* ❌ Breaks density/spacing modes */
.eds-component {
padding: 12px 8px;
}
/* ✅ Foundation token responds to data-selectable-space */
.eds-component {
padding-block: var(--eds-selectable-space-vertical);
padding-inline: var(--eds-selectable-space-horizontal);
}Component props not mapped to foundation data-* attributes.
// ❌ size prop has no effect
function MyComponent({ size }: { size: 'small' | 'large' }) {
return <div>…</div>
}
// ✅ map to the foundation scale
const SIZE_MAPPING = { small: 'sm', large: 'lg' } as const
function MyComponent({ size }: { size: 'small' | 'large' }) {
return <div data-selectable-space={SIZE_MAPPING[size]}>…</div>
}See Disabled state uses disabled tokens, never opacity above. Opacity is the most common anti-pattern for disabled.
See data-baseline enables text-box-trim for exact height. Missing data-baseline is the most common cause of a component rendering taller than the Figma frame.
These patterns cover specific edge cases. Most components don't need them — read Button/Input/Icon first.
When a component needs exact height matching (36 px, 44 px) without the border affecting the box model, use outline with a negative offset for the border, and box-shadow for the focus ring.
.eds-component {
outline: var(--eds-sizing-stroke-thin) solid var(--eds-color-border-subtle);
outline-offset: calc(-1 * var(--eds-sizing-stroke-thin));
border-radius: var(--eds-spacing-border-radius-rounded);
}
.eds-component:hover {
outline-color: var(--eds-color-border-strong);
}
.eds-component:focus-within {
outline-color: var(--eds-color-border-strong);
/* outline is already in use for the border, so the focus ring uses box-shadow */
box-shadow:
0 0 0 var(--eds-sizing-stroke-thin) var(--eds-color-bg-canvas),
0 0 0 calc(var(--eds-sizing-stroke-thin) * 2) var(--eds-color-border-focus);
}See Input/input.css for a working example.
The [data-font-family] selector sets display: block, which breaks flex layouts. Put data-font-family only on text elements, not on flex containers.
// Container handles layout — no data-font-family
<div
data-color-appearance={tone}
data-font-size="md"
data-selectable-space="md"
>
{/* Text element gets data-font-family */}
<input data-font-family="ui" data-font-size="md" />
</div>See Button/Button.tsx and Input/Input.tsx for examples.
Same mechanism as data-baseline. The fixed-height variant is:
.eds-component {
height: var(--eds-sizing-selectable-lg);
display: flex;
align-items: center;
padding-inline: var(--eds-selectable-space-horizontal);
}See Checkbox/checkbox.css and packages/eds-tokens/build/css/typography.css.
A quick checklist to scan before considering a component done:
- Calling
figma_get_design_contextalone withoutfigma_get_variable_defsfor each state - Using
opacityfor disabled states instead of--eds-color-text-disabled/--eds-color-border-disabled - Missing
data-baselineon text spans — height won't match Figma - Missing
data-color-appearancewhen Figma shows accent/danger colours — dynamic tokens won't resolve - Missing data-attributes in TSX — CSS tokens have no context to apply correct values
- Static colour tokens (
--eds-color-bg-accent-*) instead of dynamic (--eds-color-bg-*) - Hardcoded pixel values instead of
--eds-selectable-space-*/--eds-typography-gap-horizontal data-font-familyon a flex container (breaks layout viadisplay: block)- Copying data-attribute values from a similar component without verifying Figma
- EDS 1.0 tokens (
--eds-color-interactive-primary,--eds-color-text-error) - Re-implementing from scratch instead of composing
Field.Label,Icon,Input,Button
After creating all files, the agent should output a short ## Implementation notes section. Only include categories that apply:
- ✅ From Figma — key things verified and implemented from the Figma design
⚠️ Inherited, not in Figma — behaviour or UI that comes from a base component (e.g.Input,Field) but isn't shown in this component's Figma frame. Flag so the reviewer can decide whether it stays or is suppressed- 🔍 Assumptions — decisions made without Figma confirmation (guessed tokens, icons, behaviour from old component)
- ❓ Not implemented — Figma states or features that were skipped or only partially handled
- 🚧 TODOs — known gaps needing follow-up