diff --git a/.gitignore b/.gitignore
index 01fd8c2..a49d2c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ dist/
/.github/*
!/.github/workflows/
/CLAUDE.md
+/.playwright-mcp/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 84ebe0d..aa13d95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,17 @@ All notable changes to the Ippon UI packages are documented in this file, so con
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), with one entry per release listing the affected package versions.
+## 2026-07-08 — @ippon-ui/styles 0.0.9 · @ippon-ui/react 0.0.8
+
+### Added
+
+- `input-text` atom: native 48px text field carrying the visual container (border, 4px radius, background), full-width by default, with hover, focus (brand outline, hidden placeholder) and `disabled` (dimmed) states. The `-error` (setting `aria-invalid="true"`) and `-success` alternatives carry a status border and a status-colored text and placeholder, and show a tinted status background while the placeholder is visible (empty, unfocused field).
+- `label` atom: names a form control, linked to it through `for`/`id`.
+- `helper-text` atom: contextual help or feedback message linked to its control through `aria-describedby`, with `-error` and `-success` alternatives.
+- `field` molecule: vertical composition of a label, any form control and a helper text; the caller wires `for`/`id`, `aria-describedby` and the variant.
+- `IpponInputText` React component forwarding native `input` props, deriving `aria-invalid` from the `error` variant.
+- `IpponLabel`, `IpponHelperText` and `IpponField` React components.
+
## 2026-07-07 — @ippon-ui/styles 0.0.8 · @ippon-ui/react 0.0.7
### Added
diff --git a/react/package.json b/react/package.json
index e42bdb8..bdd3ce8 100644
--- a/react/package.json
+++ b/react/package.json
@@ -1,7 +1,7 @@
{
"name": "@ippon-ui/react",
"description": "Ippon UI React Component Library",
- "version": "0.0.7",
+ "version": "0.0.8",
"license": "Apache-2.0",
"repository": {
"type": "git",
@@ -39,7 +39,7 @@
},
"peerDependencies": {
"@ippon-ui/icons": "~0.0.2",
- "@ippon-ui/styles": "~0.0.8",
+ "@ippon-ui/styles": "~0.0.9",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
diff --git a/react/src/IpponField.tsx b/react/src/IpponField.tsx
new file mode 100644
index 0000000..c318e12
--- /dev/null
+++ b/react/src/IpponField.tsx
@@ -0,0 +1,12 @@
+import { clsx } from 'clsx';
+import type { DataSelectableWithChildren } from './DataSelectable.ts';
+
+type IpponFieldProps = DataSelectableWithChildren<{
+ className?: string;
+}>;
+
+export const IpponField = (props: IpponFieldProps) => (
+
+ {props.children}
+
+);
diff --git a/react/src/IpponHelperText.tsx b/react/src/IpponHelperText.tsx
new file mode 100644
index 0000000..f01b716
--- /dev/null
+++ b/react/src/IpponHelperText.tsx
@@ -0,0 +1,23 @@
+import { clsx } from 'clsx';
+import type { DataSelectableWithChildren } from './DataSelectable.ts';
+import { optionalToAlternativeClass } from './CAP.ts';
+
+type IpponHelperTextProps = DataSelectableWithChildren<{
+ id?: string;
+ variant?: 'error' | 'success';
+ className?: string;
+}>;
+
+export const IpponHelperText = (props: IpponHelperTextProps) => (
+
+ {props.children}
+
+);
diff --git a/react/src/IpponInputText.tsx b/react/src/IpponInputText.tsx
new file mode 100644
index 0000000..de14f31
--- /dev/null
+++ b/react/src/IpponInputText.tsx
@@ -0,0 +1,26 @@
+import { clsx } from 'clsx';
+import type { ComponentProps } from 'react';
+import type { DataSelectable } from './DataSelectable.ts';
+import { optionalToAlternativeClass } from './CAP.ts';
+
+type IpponInputTextProps = DataSelectable<
+ ComponentProps<'input'> & {
+ variant?: 'error' | 'success';
+ }
+>;
+
+export const IpponInputText = ({
+ variant,
+ dataSelector,
+ className,
+ type,
+ ...inputProps
+}: IpponInputTextProps) => (
+
+);
diff --git a/react/src/IpponLabel.tsx b/react/src/IpponLabel.tsx
new file mode 100644
index 0000000..4055d94
--- /dev/null
+++ b/react/src/IpponLabel.tsx
@@ -0,0 +1,17 @@
+import { clsx } from 'clsx';
+import type { DataSelectableWithChildren } from './DataSelectable.ts';
+
+type IpponLabelProps = DataSelectableWithChildren<{
+ htmlFor?: string;
+ className?: string;
+}>;
+
+export const IpponLabel = (props: IpponLabelProps) => (
+
+);
diff --git a/react/src/index.ts b/react/src/index.ts
index 87e0c69..34c06f8 100644
--- a/react/src/index.ts
+++ b/react/src/index.ts
@@ -4,11 +4,15 @@ export { IpponButtonCard } from './IpponButtonCard.tsx';
export { IpponCard } from './IpponCard.tsx';
export { IpponContainer } from './IpponContainer.tsx';
export { IpponDropdown } from './IpponDropdown.tsx';
+export { IpponField } from './IpponField.tsx';
export { IpponGrid, IpponGridSlot } from './IpponGrid.tsx';
+export { IpponHelperText } from './IpponHelperText.tsx';
export { IpponHSpace, IpponHSpaceSlot } from './IpponHSpace.tsx';
export { IpponIcon } from './IpponIcon.tsx';
export { IpponImportFile } from './IpponImportFile.tsx';
+export { IpponInputText } from './IpponInputText.tsx';
export { IpponIon } from './IpponIon.tsx';
+export { IpponLabel } from './IpponLabel.tsx';
export { IpponLinkButton } from './IpponLinkButton.tsx';
export { IpponLinkButtonCard } from './IpponLinkButtonCard.tsx';
export { IpponMeter } from './IpponMeter.tsx';
diff --git a/react/stories/IpponField.stories.tsx b/react/stories/IpponField.stories.tsx
new file mode 100644
index 0000000..2082b0d
--- /dev/null
+++ b/react/stories/IpponField.stories.tsx
@@ -0,0 +1,58 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { IpponField } from '../src/IpponField.tsx';
+import { IpponHelperText } from '../src/IpponHelperText.tsx';
+import { IpponInputText } from '../src/IpponInputText.tsx';
+import { IpponLabel } from '../src/IpponLabel.tsx';
+
+type IpponFieldStoryArgs = {
+ variant?: 'error' | 'success';
+ disabled?: boolean;
+ helper?: string;
+};
+
+const meta = {
+ title: 'Molecule/Field',
+ argTypes: {
+ variant: {
+ control: 'inline-radio',
+ options: [undefined, 'error', 'success'],
+ },
+ disabled: { control: 'boolean' },
+ },
+ args: {
+ helper: 'Helper text',
+ },
+ render: (args) => (
+
+ Label
+
+
+ {args.helper}
+
+
+ ),
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Error: Story = {
+ args: { variant: 'error', helper: 'Error message' },
+};
+
+export const Success: Story = {
+ args: { variant: 'success' },
+};
+
+export const Disabled: Story = {
+ args: { disabled: true },
+};
diff --git a/react/stories/IpponHelperText.stories.tsx b/react/stories/IpponHelperText.stories.tsx
new file mode 100644
index 0000000..0a34ac4
--- /dev/null
+++ b/react/stories/IpponHelperText.stories.tsx
@@ -0,0 +1,30 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { IpponHelperText } from '../src/IpponHelperText.tsx';
+
+const meta = {
+ title: 'Atom/HelperText',
+ component: IpponHelperText,
+ args: {
+ children: 'Helper text',
+ },
+ argTypes: {
+ variant: {
+ control: 'inline-radio',
+ options: [undefined, 'error', 'success'],
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Error: Story = {
+ args: { variant: 'error', children: 'Error message' },
+};
+
+export const Success: Story = {
+ args: { variant: 'success', children: 'Success message' },
+};
diff --git a/react/stories/IpponInputText.stories.tsx b/react/stories/IpponInputText.stories.tsx
new file mode 100644
index 0000000..6da2fa7
--- /dev/null
+++ b/react/stories/IpponInputText.stories.tsx
@@ -0,0 +1,34 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { IpponInputText } from '../src/IpponInputText.tsx';
+
+const meta = {
+ title: 'Atom/InputText',
+ component: IpponInputText,
+ args: {
+ placeholder: 'Placeholder',
+ },
+ argTypes: {
+ variant: {
+ control: 'inline-radio',
+ options: [undefined, 'error', 'success'],
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Error: Story = {
+ args: { variant: 'error', defaultValue: 'Value' },
+};
+
+export const Success: Story = {
+ args: { variant: 'success', defaultValue: 'Value' },
+};
+
+export const Disabled: Story = {
+ args: { disabled: true },
+};
diff --git a/react/stories/IpponLabel.stories.tsx b/react/stories/IpponLabel.stories.tsx
new file mode 100644
index 0000000..b2b5e44
--- /dev/null
+++ b/react/stories/IpponLabel.stories.tsx
@@ -0,0 +1,16 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { IpponLabel } from '../src/IpponLabel.tsx';
+
+const meta = {
+ title: 'Atom/Label',
+ component: IpponLabel,
+ args: {
+ children: 'Label',
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/react/test/IpponField.spec.tsx b/react/test/IpponField.spec.tsx
new file mode 100644
index 0000000..b45b898
--- /dev/null
+++ b/react/test/IpponField.spec.tsx
@@ -0,0 +1,31 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { render, screen, configure, cleanup } from '@testing-library/react';
+import '@testing-library/jest-dom/vitest';
+import { IpponField, IpponHelperText, IpponInputText, IpponLabel } from '../src';
+
+configure({
+ testIdAttribute: 'data-selector',
+});
+
+describe('IpponField', () => {
+ afterEach(cleanup);
+
+ it('should be like pattern library', () => {
+ render(
+
+ Label
+
+ Helper text
+ ,
+ );
+
+ expect(screen.getByTestId('ippon-field')).toHaveClass('ippon-field');
+ expect(screen.getByLabelText('Label')).toHaveAccessibleDescription('Helper text');
+ });
+
+ it('should merge additional className', () => {
+ render();
+
+ expect(screen.getByTestId('ippon-field')).toHaveClass('ippon-field', '-custom');
+ });
+});
diff --git a/react/test/IpponHelperText.spec.tsx b/react/test/IpponHelperText.spec.tsx
new file mode 100644
index 0000000..4e7888b
--- /dev/null
+++ b/react/test/IpponHelperText.spec.tsx
@@ -0,0 +1,40 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { render, screen, configure, cleanup } from '@testing-library/react';
+import '@testing-library/jest-dom/vitest';
+import { IpponHelperText } from '../src';
+
+configure({
+ testIdAttribute: 'data-selector',
+});
+
+const getIpponHelperText = () => screen.getByTestId('ippon-helper-text');
+
+describe('IpponHelperText', () => {
+ afterEach(cleanup);
+
+ it('should be like pattern library', () => {
+ render(
+
+ Helper text
+ ,
+ );
+
+ const helper = getIpponHelperText();
+
+ expect(helper).toHaveClass('ippon-helper-text');
+ expect(helper).toHaveAttribute('id', 'input-helper');
+ expect(helper).toHaveTextContent('Helper text');
+ });
+
+ it.each(['error', 'success'] as const)('should have variant %s', (variant) => {
+ render();
+
+ expect(getIpponHelperText()).toHaveClass('ippon-helper-text', `-${variant}`);
+ });
+
+ it('should merge additional className', () => {
+ render();
+
+ expect(getIpponHelperText()).toHaveClass('ippon-helper-text', '-custom');
+ });
+});
diff --git a/react/test/IpponInputText.spec.tsx b/react/test/IpponInputText.spec.tsx
new file mode 100644
index 0000000..8da1721
--- /dev/null
+++ b/react/test/IpponInputText.spec.tsx
@@ -0,0 +1,77 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { render, screen, configure, cleanup } from '@testing-library/react';
+import '@testing-library/jest-dom/vitest';
+import { IpponInputText } from '../src';
+
+configure({
+ testIdAttribute: 'data-selector',
+});
+
+const getIpponInputText = () => screen.getByTestId('ippon-input-text');
+
+describe('IpponInputText', () => {
+ afterEach(cleanup);
+
+ it('should be like pattern library', () => {
+ render();
+
+ const input = getIpponInputText();
+
+ expect(input).toHaveClass('ippon-input-text');
+ expect(input.tagName).toBe('INPUT');
+ expect(input).toHaveAttribute('type', 'text');
+ });
+
+ it.each(['error', 'success'] as const)('should have variant %s', (variant) => {
+ render();
+
+ expect(getIpponInputText()).toHaveClass('ippon-input-text', `-${variant}`);
+ });
+
+ it('should forward native input props', () => {
+ render(
+ ,
+ );
+
+ const input = getIpponInputText();
+
+ expect(input).toHaveAttribute('placeholder', 'Placeholder');
+ expect(input).toBeDisabled();
+ expect(input).toHaveAttribute('aria-describedby', 'helper');
+ });
+
+ it('should be invalid with variant error', () => {
+ render();
+
+ expect(getIpponInputText()).toHaveAttribute('aria-invalid', 'true');
+ });
+
+ it('should not be invalid without variant error', () => {
+ render();
+
+ expect(getIpponInputText()).not.toHaveAttribute('aria-invalid');
+ });
+
+ it('should keep the given aria-invalid', () => {
+ render();
+
+ expect(getIpponInputText()).toHaveAttribute('aria-invalid', 'false');
+ });
+
+ it('should keep the given type', () => {
+ render();
+
+ expect(getIpponInputText()).toHaveAttribute('type', 'search');
+ });
+
+ it('should merge additional className', () => {
+ render();
+
+ expect(getIpponInputText()).toHaveClass('ippon-input-text', '-custom');
+ });
+});
diff --git a/react/test/IpponLabel.spec.tsx b/react/test/IpponLabel.spec.tsx
new file mode 100644
index 0000000..7a223a2
--- /dev/null
+++ b/react/test/IpponLabel.spec.tsx
@@ -0,0 +1,35 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { render, screen, configure, cleanup } from '@testing-library/react';
+import '@testing-library/jest-dom/vitest';
+import { IpponLabel } from '../src';
+
+configure({
+ testIdAttribute: 'data-selector',
+});
+
+const getIpponLabel = () => screen.getByTestId('ippon-label');
+
+describe('IpponLabel', () => {
+ afterEach(cleanup);
+
+ it('should be like pattern library', () => {
+ render(
+
+ Label
+ ,
+ );
+
+ const label = getIpponLabel();
+
+ expect(label).toHaveClass('ippon-label');
+ expect(label.tagName).toBe('LABEL');
+ expect(label).toHaveAttribute('for', 'input');
+ expect(label).toHaveTextContent('Label');
+ });
+
+ it('should merge additional className', () => {
+ render();
+
+ expect(getIpponLabel()).toHaveClass('ippon-label', '-custom');
+ });
+});
diff --git a/styles/package.json b/styles/package.json
index 2f06787..50d466b 100644
--- a/styles/package.json
+++ b/styles/package.json
@@ -1,6 +1,6 @@
{
"name": "@ippon-ui/styles",
- "version": "0.0.8",
+ "version": "0.0.9",
"description": "Ippon UI Pattern Library",
"repository": {
"type": "git",
diff --git a/styles/src/atom/_atom.scss b/styles/src/atom/_atom.scss
index 7315a0f..e086851 100644
--- a/styles/src/atom/_atom.scss
+++ b/styles/src/atom/_atom.scss
@@ -1,6 +1,9 @@
@use 'badge/badge';
@use 'button/button';
+@use 'helper-text/helper-text';
@use 'icon/icon';
+@use 'input-text/input-text';
+@use 'label/label';
@use 'meter/meter';
@use 'progress/progress';
@use 'separator/separator';
diff --git a/styles/src/atom/atom.pug b/styles/src/atom/atom.pug
index ec36831..4b172c4 100644
--- a/styles/src/atom/atom.pug
+++ b/styles/src/atom/atom.pug
@@ -16,10 +16,16 @@ block content
include:componentDoc(height=620) badge/badge.md
.tikui-vertical-spacing--line
include:componentDoc(height=740) button/button.md
+ .tikui-vertical-spacing--line
+ include:componentDoc(height=40) helper-text/helper-text.md
.tikui-vertical-spacing--line
include:componentDoc(height=50) icon/icon.md
+ .tikui-vertical-spacing--line
+ include:componentDoc(height=450) input-text/input-text.md
.tikui-vertical-spacing--line
include:componentDoc(height=60) ion/ion.md
+ .tikui-vertical-spacing--line
+ include:componentDoc(height=40) label/label.md
.tikui-vertical-spacing--line
include:componentDoc(height=150) meter/meter.md
.tikui-vertical-spacing--line
diff --git a/styles/src/atom/helper-text/_helper-text.scss b/styles/src/atom/helper-text/_helper-text.scss
new file mode 100644
index 0000000..0d9398e
--- /dev/null
+++ b/styles/src/atom/helper-text/_helper-text.scss
@@ -0,0 +1,15 @@
+@use '../../quark/typography';
+
+.ippon-helper-text {
+ @include typography.body-small;
+
+ color: var(--ippon-color-neutral-text-icon-tertiary);
+
+ &.-error {
+ color: var(--ippon-color-error-text-icon-on-secondary);
+ }
+
+ &.-success {
+ color: var(--ippon-color-success-text-icon-on-secondary);
+ }
+}
diff --git a/styles/src/atom/helper-text/helper-text.code.pug b/styles/src/atom/helper-text/helper-text.code.pug
new file mode 100644
index 0000000..65018e6
--- /dev/null
+++ b/styles/src/atom/helper-text/helper-text.code.pug
@@ -0,0 +1,5 @@
+include helper-text.mixin.pug
+
++ippon-helper-text Helper text
++ippon-helper-text({ variant: 'error' }) Error message
++ippon-helper-text({ variant: 'success' }) Success message
diff --git a/styles/src/atom/helper-text/helper-text.md b/styles/src/atom/helper-text/helper-text.md
new file mode 100644
index 0000000..916a9c5
--- /dev/null
+++ b/styles/src/atom/helper-text/helper-text.md
@@ -0,0 +1,13 @@
+## Helper text
+
+A contextual help or feedback message for a form control.
+
+**Alternatives:**
+
+- Default
+- Error `-error`
+- Success `-success`
+
+**Accessibility:**
+
+- Link it to its control through `aria-describedby` so assistive technologies announce it as the control description
diff --git a/styles/src/atom/helper-text/helper-text.mixin.pug b/styles/src/atom/helper-text/helper-text.mixin.pug
new file mode 100644
index 0000000..1098ce7
--- /dev/null
+++ b/styles/src/atom/helper-text/helper-text.mixin.pug
@@ -0,0 +1,5 @@
+mixin ippon-helper-text(options)
+ - const { id, variant } = options || {};
+ - const variantClass = variant ? `-${variant}` : null;
+ span.ippon-helper-text(class=variantClass, id=id)
+ block
diff --git a/styles/src/atom/helper-text/helper-text.render.pug b/styles/src/atom/helper-text/helper-text.render.pug
new file mode 100644
index 0000000..1b6202e
--- /dev/null
+++ b/styles/src/atom/helper-text/helper-text.render.pug
@@ -0,0 +1,5 @@
+extends /layout
+
+block body
+ div(style='display: flex; gap: 1rem')
+ include helper-text.code.pug
diff --git a/styles/src/atom/input-text/_input-text.scss b/styles/src/atom/input-text/_input-text.scss
new file mode 100644
index 0000000..8ee303f
--- /dev/null
+++ b/styles/src/atom/input-text/_input-text.scss
@@ -0,0 +1,79 @@
+@use '../../quark/typography';
+
+.ippon-input-text {
+ box-sizing: border-box;
+ border: 1px solid var(--ippon-color-neutral-border);
+ border-radius: var(--ippon-radius-m);
+ background-color: var(--ippon-color-neutral-surface-primary);
+ padding: var(--ippon-size-12) var(--ippon-size-16);
+ width: 100%;
+ height: var(--ippon-size-48);
+ color: var(--ippon-color-neutral-text-icon-primary);
+
+ @include typography.body-medium;
+
+ &::placeholder {
+ color: var(--ippon-color-neutral-text-icon-tertiary);
+ }
+
+ &:enabled:hover {
+ background-color: var(--ippon-color-neutral-surface-primary-hover);
+ }
+
+ &:enabled:focus {
+ outline: 2px solid var(--ippon-color-brand-border);
+ outline-offset: 2px;
+ border-color: var(--ippon-color-brand-border);
+
+ &::placeholder {
+ color: transparent;
+ }
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+
+ &.-error {
+ border-color: var(--ippon-color-error-border);
+ color: var(--ippon-color-error-text-icon-on-secondary);
+
+ &::placeholder {
+ color: var(--ippon-color-error-text-icon-on-secondary);
+ }
+
+ &:enabled:placeholder-shown:not(:focus) {
+ background-color: var(--ippon-color-error-surface-secondary);
+
+ &:hover {
+ background-color: var(--ippon-color-error-surface-secondary-hover);
+ }
+ }
+
+ &:enabled:focus {
+ border-color: var(--ippon-color-error-border);
+ }
+ }
+
+ &.-success {
+ border-color: var(--ippon-color-success-border);
+ color: var(--ippon-color-success-text-icon-on-secondary);
+
+ &::placeholder {
+ color: var(--ippon-color-success-text-icon-on-secondary);
+ }
+
+ &:enabled:placeholder-shown:not(:focus) {
+ background-color: var(--ippon-color-success-surface-secondary);
+
+ &:hover {
+ background-color: var(--ippon-color-success-surface-secondary-hover);
+ }
+ }
+
+ &:enabled:focus {
+ border-color: var(--ippon-color-success-border);
+ }
+ }
+}
diff --git a/styles/src/atom/input-text/input-text.code.pug b/styles/src/atom/input-text/input-text.code.pug
new file mode 100644
index 0000000..140b01f
--- /dev/null
+++ b/styles/src/atom/input-text/input-text.code.pug
@@ -0,0 +1,9 @@
+include input-text.mixin.pug
+
++ippon-input-text({ placeholder: 'Placeholder' })
++ippon-input-text({ value: 'Value' })
++ippon-input-text({ variant: 'error', placeholder: 'Placeholder' })
++ippon-input-text({ variant: 'error', placeholder: 'Placeholder', value: 'Value' })
++ippon-input-text({ variant: 'success', placeholder: 'Placeholder' })
++ippon-input-text({ variant: 'success', placeholder: 'Placeholder', value: 'Value' })
++ippon-input-text({ placeholder: 'Placeholder', disabled: true })
diff --git a/styles/src/atom/input-text/input-text.md b/styles/src/atom/input-text/input-text.md
new file mode 100644
index 0000000..718e6fb
--- /dev/null
+++ b/styles/src/atom/input-text/input-text.md
@@ -0,0 +1,23 @@
+## Input text
+
+A native text field carrying the visual container: border, radius and background.
+
+**Alternatives:**
+
+- Default
+- Error `-error` (adds `aria-invalid="true"`, error border; tinted background while the placeholder shows)
+- Success `-success` (success border; same background mechanics as error)
+
+**States:**
+
+- Hover (highlighted background)
+- Focus (brand outline, hidden placeholder; an error or success field keeps its status border)
+- Placeholder shown (an empty, unfocused error or success field gets the tinted background; focused, filled or placeholder-less it keeps the resting one)
+- Disabled `disabled` (dimmed field and `not-allowed` cursor)
+
+**Accessibility:**
+
+- Name the field with a _Label_ atom linked through `for`/`id`
+- Describe it with a _Helper text_ atom linked through `aria-describedby`
+
+> The placeholder is a hint, not a label: it disappears on input.
diff --git a/styles/src/atom/input-text/input-text.mixin.pug b/styles/src/atom/input-text/input-text.mixin.pug
new file mode 100644
index 0000000..1909f8d
--- /dev/null
+++ b/styles/src/atom/input-text/input-text.mixin.pug
@@ -0,0 +1,14 @@
+mixin ippon-input-text(options)
+ - const { id, type, placeholder, value, variant, disabled, describedBy } = options || {};
+ - const variantClass = variant ? `-${variant}` : null;
+ - const invalid = variant === 'error' ? 'true' : null;
+ input.ippon-input-text(
+ class=variantClass,
+ id=id,
+ type=type || 'text',
+ placeholder=placeholder,
+ value=value,
+ disabled=disabled,
+ aria-describedby=describedBy,
+ aria-invalid=invalid
+ )
diff --git a/styles/src/atom/input-text/input-text.render.pug b/styles/src/atom/input-text/input-text.render.pug
new file mode 100644
index 0000000..c894c55
--- /dev/null
+++ b/styles/src/atom/input-text/input-text.render.pug
@@ -0,0 +1,5 @@
+extends /layout
+
+block body
+ div(style='display: flex; flex-direction: column; gap: 1rem')
+ include input-text.code.pug
diff --git a/styles/src/atom/label/_label.scss b/styles/src/atom/label/_label.scss
new file mode 100644
index 0000000..19f13f4
--- /dev/null
+++ b/styles/src/atom/label/_label.scss
@@ -0,0 +1,7 @@
+@use '../../quark/typography';
+
+.ippon-label {
+ @include typography.body-medium;
+
+ color: var(--ippon-color-neutral-text-icon-tertiary);
+}
diff --git a/styles/src/atom/label/label.code.pug b/styles/src/atom/label/label.code.pug
new file mode 100644
index 0000000..58ac1b8
--- /dev/null
+++ b/styles/src/atom/label/label.code.pug
@@ -0,0 +1,3 @@
+include label.mixin.pug
+
++ippon-label({ for: 'example-input' }) Label
diff --git a/styles/src/atom/label/label.md b/styles/src/atom/label/label.md
new file mode 100644
index 0000000..60bc930
--- /dev/null
+++ b/styles/src/atom/label/label.md
@@ -0,0 +1,7 @@
+## Label
+
+A label naming a form control.
+
+**Accessibility:**
+
+- Link it to its control through `for`/`id`: clicking the label focuses the control and assistive technologies announce it as the control name
diff --git a/styles/src/atom/label/label.mixin.pug b/styles/src/atom/label/label.mixin.pug
new file mode 100644
index 0000000..65991af
--- /dev/null
+++ b/styles/src/atom/label/label.mixin.pug
@@ -0,0 +1,4 @@
+mixin ippon-label(options)
+ - const { for: forId } = options || {};
+ label.ippon-label(for=forId)
+ block
diff --git a/styles/src/atom/label/label.render.pug b/styles/src/atom/label/label.render.pug
new file mode 100644
index 0000000..3a57904
--- /dev/null
+++ b/styles/src/atom/label/label.render.pug
@@ -0,0 +1,4 @@
+extends /layout
+
+block body
+ include label.code.pug
diff --git a/styles/src/molecule/_molecule.scss b/styles/src/molecule/_molecule.scss
index 72c3aa3..a71a866 100644
--- a/styles/src/molecule/_molecule.scss
+++ b/styles/src/molecule/_molecule.scss
@@ -1,3 +1,4 @@
+@use 'field/field';
@use 'import-file/import-file';
@use 'table/table';
@use 'tabs/tabs';
diff --git a/styles/src/molecule/field/_field.scss b/styles/src/molecule/field/_field.scss
new file mode 100644
index 0000000..78acc2f
--- /dev/null
+++ b/styles/src/molecule/field/_field.scss
@@ -0,0 +1,5 @@
+.ippon-field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ippon-size-4);
+}
diff --git a/styles/src/molecule/field/field.code.pug b/styles/src/molecule/field/field.code.pug
new file mode 100644
index 0000000..742be24
--- /dev/null
+++ b/styles/src/molecule/field/field.code.pug
@@ -0,0 +1,18 @@
+include /atom/helper-text/helper-text.mixin.pug
+include /atom/input-text/input-text.mixin.pug
+include /atom/label/label.mixin.pug
+
+include field.mixin.pug
+
+mixin ippon-field-example(options)
+ - const { id, helper, variant, ...inputOptions } = options || {};
+ - const helperId = `${id}-helper`;
+ +ippon-field
+ +ippon-label({ for: id }) Label
+ +ippon-input-text({ id, variant, describedBy: helperId, ...inputOptions })
+ +ippon-helper-text({ id: helperId, variant })= helper
+
++ippon-field-example({ id: 'default-input', placeholder: 'Placeholder', helper: 'Helper text' })
++ippon-field-example({ id: 'error-input', placeholder: 'Placeholder', value: 'Value', helper: 'Error message', variant: 'error' })
++ippon-field-example({ id: 'success-input', placeholder: 'Placeholder', value: 'Value', helper: 'Helper text', variant: 'success' })
++ippon-field-example({ id: 'disabled-input', placeholder: 'Placeholder', helper: 'Helper text', disabled: true })
diff --git a/styles/src/molecule/field/field.md b/styles/src/molecule/field/field.md
new file mode 100644
index 0000000..2380069
--- /dev/null
+++ b/styles/src/molecule/field/field.md
@@ -0,0 +1,12 @@
+## Field
+
+A vertical composition of a _Label_ atom, any form control (_Input text_ today, textarea or select tomorrow) and a _Helper text_ atom.
+
+**Wiring (done by the caller):**
+
+- Link the label to the control through `for`/`id`
+- Link the helper text to the control through `aria-describedby`
+- Pass the same variant (`error` or `success`) to the control and the helper text
+- Give a variant control a placeholder: the tinted background only shows through `:placeholder-shown`
+
+> The field itself carries no state: the control and the helper text do.
diff --git a/styles/src/molecule/field/field.mixin.pug b/styles/src/molecule/field/field.mixin.pug
new file mode 100644
index 0000000..69de4f4
--- /dev/null
+++ b/styles/src/molecule/field/field.mixin.pug
@@ -0,0 +1,3 @@
+mixin ippon-field
+ .ippon-field
+ block
diff --git a/styles/src/molecule/field/field.render.pug b/styles/src/molecule/field/field.render.pug
new file mode 100644
index 0000000..f16fe7e
--- /dev/null
+++ b/styles/src/molecule/field/field.render.pug
@@ -0,0 +1,5 @@
+extends /layout
+
+block body
+ div(style='display: flex; flex-direction: column; gap: 1.5rem')
+ include field.code.pug
diff --git a/styles/src/molecule/molecule.pug b/styles/src/molecule/molecule.pug
index 337215e..2c6a365 100644
--- a/styles/src/molecule/molecule.pug
+++ b/styles/src/molecule/molecule.pug
@@ -12,6 +12,8 @@ block content
h1#molecules.tikui-title-main Molecules
.tikui-vertical-spacing--line
include /documentation/atomic-design/quote/molecule
+ .tikui-vertical-spacing--line
+ include:componentDoc(height=480) field/field.md
.tikui-vertical-spacing--line
include:componentDoc(height=370) import-file/import-file.md
.tikui-vertical-spacing--line