diff --git a/src/design-system/primitives/FormField/FormField.module.css b/src/design-system/primitives/FormField/FormField.module.css
new file mode 100644
index 0000000..b0524e9
--- /dev/null
+++ b/src/design-system/primitives/FormField/FormField.module.css
@@ -0,0 +1,18 @@
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ font-family: var(--font-body);
+}
+
+.hint {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+
+.error {
+ font-size: 11px;
+ color: var(--error);
+ margin-top: 4px;
+}
diff --git a/src/design-system/primitives/FormField/FormField.test.tsx b/src/design-system/primitives/FormField/FormField.test.tsx
new file mode 100644
index 0000000..333b468
--- /dev/null
+++ b/src/design-system/primitives/FormField/FormField.test.tsx
@@ -0,0 +1,88 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { FormField } from './FormField'
+
+describe('FormField', () => {
+ it('renders label and children', () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.getByText('Username')).toBeInTheDocument()
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ })
+
+ it('renders hint text when no error', () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.getByText('We will never share your email')).toBeInTheDocument()
+ })
+
+ it('renders error instead of hint when error is provided', () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.getByText('Invalid email address')).toBeInTheDocument()
+ expect(screen.queryByText('We will never share your email')).not.toBeInTheDocument()
+ })
+
+ it('shows required asterisk via Label when required is true', () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.getByText('*')).toBeInTheDocument()
+ })
+
+ it('adds error class to wrapper when error is present', () => {
+ const { container } = render(
+
+
+ ,
+ )
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper.className).toMatch(/error/)
+ })
+
+ it('does not add error class when no error', () => {
+ const { container } = render(
+
+
+ ,
+ )
+ const wrapper = container.firstChild as HTMLElement
+ expect(wrapper.className).not.toMatch(/error/)
+ })
+
+ it('renders children without label when label prop is omitted', () => {
+ render(
+
+
+ ,
+ )
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
+ expect(screen.queryByRole('label')).not.toBeInTheDocument()
+ })
+
+ it('associates label with input via htmlFor', () => {
+ render(
+
+
+ ,
+ )
+ const label = screen.getByText('Search').closest('label')
+ expect(label).toHaveAttribute('for', 'search-input')
+ })
+})
diff --git a/src/design-system/primitives/FormField/FormField.tsx b/src/design-system/primitives/FormField/FormField.tsx
new file mode 100644
index 0000000..84d3893
--- /dev/null
+++ b/src/design-system/primitives/FormField/FormField.tsx
@@ -0,0 +1,47 @@
+import type { ReactNode } from 'react'
+import { Label } from '../Label/Label'
+import styles from './FormField.module.css'
+
+interface FormFieldProps {
+ label?: string
+ htmlFor?: string
+ required?: boolean
+ error?: string
+ hint?: string
+ children: ReactNode
+ className?: string
+}
+
+export function FormField({
+ label,
+ htmlFor,
+ required,
+ error,
+ hint,
+ children,
+ className,
+}: FormFieldProps) {
+ const wrapperClass = [styles.wrapper, error ? styles.error : '', className ?? '']
+ .filter(Boolean)
+ .join(' ')
+
+ return (
+
+ {label && (
+
+ )}
+ {children}
+ {error ? (
+
+ {error}
+
+ ) : hint ? (
+ {hint}
+ ) : null}
+
+ )
+}
+
+FormField.displayName = 'FormField'