A multi-line text input for longer-form entry — label, body, and character counter.
4781:35856 in the 2026 Working File. State = Default | Focused | Error | Disabled × hasValue = False | True gives eight variants, the label and subtext are shared instances rather than redrawn, focus and error borders are distinct, the character counter matches its sample exactly, and the local layers now read ValueContainer → Value. Two family questions were settled here rather than left to recur: hasValue is the pattern for fields whose filled state changes geometry, and the error color on a validation message belongs to the shared Subtext Message component. All four DS Health traits pass; the only item still open is Code Connect, blocked until the native library exists.Typical mobile contexts: feedback forms, message composers, notes, support request descriptions.
State × hasValue schema mirrors Input Field's by design — family consistency rather than duplication.FormGroup Header for labelling and a Subtext Message for supporting copy and the character count. Nothing external required to render a complete field.State = Default | Focused | Error | Disabled × hasValue, PascalCase per §1 with Title Case values — and hasValue is now the documented family pattern for fields whose filled state changes geometry. Local layers follow the §3 vocabulary as ValueContainer → Value, the label and subtext come from shared instances, and the character counter matches its sample content.FormGroup Header and Subtext Message instances, so label and supporting-text changes propagate from one source across the family rather than being re-authored per component.| State | iOS | Android | Figma Property | Notes |
|---|---|---|---|---|
| Default | Yes | Yes | State=Default | 1px #D7E0EF border, white bg. No resize glyph — removed in v2.2. |
| Focused | Yes | Yes | State=Focused | #005CE5 border. Matches SwiftUI @FocusState / Compose FocusRequester vocabulary. |
| Error | Yes | Yes | State=Error | #D61B2C border. Validation copy goes in the Subtext Message row below. |
| Disabled | Yes | Yes | State=Disabled | #EEF2F9 bg, border hidden, text #C2CFE5. |
- Boolean property migrated to True/False. v2.0: Rebuilt on node
4781:35856in the 2026 Working File.isFilledis now a real Figma boolean withTrue/Falsevalues, mapping directly to SwiftBool/ KotlinBoolean. Matches the fix Input Field shipped in 1.1.0. (C2 · Rename) C2 · Variant & Property Naming -
State=Activerenamed toFocused. v2.0: The interaction axis is nowState = Default | Focused | Error | Disabled, matching SwiftUI@FocusState/ ComposeFocusRequestervocabulary and theFocusedvalue in §5 of the Property Naming Guidelines. Consistent with Search Field. (C2 · Rename) C2 · Variant & Property Naming - Label and supporting-text slots added. v2.0: Each variant now composes a
FormGroup Headerinstance above the field and aSubtext Messageinstance below it, so labelling and validation copy are part of the component rather than re-implemented per screen. Delivered as shared instances rather than bespoke slots — a better outcome than recommended. (C5 · Slot) C5 · Interaction State Coverage - Character-count affordance added. v2.0: The
Subtext Messageinstance carries a trailing counter (0/100) alongside the supporting-text row, matching Material 3'ssupportingText+counterpattern. The DS can now represent limit state. (C5 · Slot) C5 · Interaction State Coverage - Confirmed as a standalone component. v2.1: Closed by owner decision — Text Area stays its own component rather than folding into Input Field as a
multilineflag. The original consolidation case rested on Text Area duplicating Input Field's schema with no anatomy of its own; it now composes sharedFormGroup HeaderandSubtext Messageinstances and owns a character-count affordance Input Field doesn't need. The parallelState × isFilledschema is deliberate family consistency, not duplication. (Family) C4 · Native Mappability -
State=Errorexception confirmed. v2.1: Closed by owner decision at family level —Errorstays on theStateaxis across Form Elements as a deliberate exception to the State/Status rule, matching how most design systems model form-field validation. Keeps Text Area at 8 variants rather than the 12 a split would require. Applies equally to Search Field and Amount Text Field. (C2 · Property) C2 · Variant & Property Naming - Desktop resize handle removed. v2.2: The
text-area iconframe is gone from all eight variants, taking the browser-onlyresize: bothaffordance with it. NativeTextField(axis: .vertical)/OutlinedTextField(maxLines = n)auto-grow without a user-facing grip, so the component now maps cleanly. Field heights normalised to 50px and every variant is a uniform 94px tall. Also closes the raster-vs-vector question, which is moot now the glyph is gone. (C4 · C6 · Asset) C4 · Native Mappability - Token namespace resolved — generic tokens applied. v2.2: Closed by owner confirmation. The component-scoped
main/text-area/*namespace was dropped in favour of the shared generic token scale, so there is no longer a parallel token set that can drift frommain/input-field/*. Same direction Search Field took. (C1 · Token) C1 · Layer Structure & Naming - Character counter corrected in the filled variants. v2.2: The four
hasValue=Truevariants now read71/100, matching their body copy, instead of the previous0/100. (C1) C1 · Layer Structure & Naming - Boolean renamed
isFilled→hasValue. v2.3: The variant axis is nowState×hasValue. Resolves the catalog gap in the right direction — §2 assigns thehasprefix to presence of content, andhasValuepairs with theValuetext property in §3, whereisFilledread as a visual descriptor. Input Field and Amount Text Field should follow so the family stays aligned. (C2 · Rename) C2 · Variant & Property Naming - Nested duplicate
text-containerremoved. v2.3:text-containernow holds#text-labeldirectly in every variant — the redundant same-size wrapper inside it is gone, and the layer tree is unambiguous. (C1) C1 · Layer Structure & Naming - Sample content matches
hasValuein all eight variants. v2.4: EveryhasValue=Falsevariant now reads Write your message… with a0/100counter, and everyhasValue=Truevariant carries the body sentence with71/100— verified across all eight by text scan. Field heights follow the content: 34px for one line of placeholder, 50px for two lines of copy. Closes the last C1 finding. (C1) C1 · Layer Structure & Naming - Character-count sample content corrected. v2.4: Verified on the live node. The counter now reads
0/100in thehasValue=Falsevariants and71/100in thehasValue=Truevariants, matching the 71-character sample string exactly. Previously the count and the content disagreed, which made the counter look decorative rather than bound. (C1) C1 · Layer Structure & Naming - Local layer names cleaned up. v2.5: Verified on the live node.
text-container→ValueContainerand#text-label→Valueacross all eight variants, so the entered text exposes as a singleValueproperty matching the §3 vocabulary. The bordered frame keeps the nameText Area; it is the only frame at that level and Figma already nests it under the component, so the shadowing is accepted rather than churned. The remaining#labeland#subtextlayers belong to the sharedFormGroup HeaderandSubtext Messageinstances and are their owner’s to rename. (C1 · Rename) C1 · Layer Structure & Naming -
hasValuesettled as a family rule. v2.5: Both approaches in Form Elements are correct, and the rule that separates them is layout. Where the filled state changes the component’s geometry it needs an explicit boolean, because the two shapes cannot be one variant: Text Area is 78px empty and 94px filled, sohasValueearns its place and the set is eight. Where filled changes only color and affordance — Search Field, whose height is constant at 56px — the state is derived from value presence in code and the set stays at four. A consumer can now tell from the geometry alone which pattern a field follows.hasValuegoes into the §2 approved boolean catalog on that basis. (C2 · Family) C2 · Variant & Property Naming - Error-state subtext coloring settled. v2.5: The family rule is that a validation message carries the error color — Amount Text Field turns its
HelperText#D61B2Cand is the reference. Text Area’s subtext is not a local layer: it is the sharedSubtext Messageinstance, which also carries the character counter, so the error color belongs to that component rather than to this one. Recorded here as a shared-component follow-up rather than a Text Area defect, so the difference reads as a known boundary instead of an inconsistency. (C5 · Token) C5 · Interaction State Coverage
Read off node 4781:35856. ROLE is the State value; the second column is the ELEMENT. Token paths are indicative — variable bindings are not readable through the Talk To Figma plugin.
| Role | Element | Token | Value |
|---|---|---|---|
| Default | Field background | surface/default | #FFFFFF |
| — | Border | border/default | #D7E0EF |
| — | Value (empty) | text/placeholder | #90A8D0 |
| — | Value (filled) | text/primary | #0A2757 |
| — | Label | text/primary | #0A2757 |
| — | Subtext | text/secondary | #6780A9 |
| Focused | Field background | surface/default | #FFFFFF |
| — | Border | border/focused | #005CE5 |
| — | Value (empty) | text/placeholder | #90A8D0 |
| — | Value (filled) | text/primary | #0A2757 |
| — | Label | text/primary | #0A2757 |
| — | Subtext | text/secondary | #6780A9 |
| Error | Field background | surface/default | #FFFFFF |
| — | Border | border/error | #D61B2C |
| — | Value (empty) | text/placeholder | #90A8D0 |
| — | Value (filled) | text/primary | #0A2757 |
| — | Label | text/primary | #0A2757 |
| — | Subtext | text/secondary | #6780A9 |
| Disabled | Field background | surface/disabled | #EEF2F9 |
| — | Border | – | – |
| — | Value (empty) | text/placeholder | #90A8D0 |
| — | Value (filled) | text/placeholder | #90A8D0 |
| — | Label | text/primary | #0A2757 |
| — | Subtext | text/secondary | #6780A9 |
iOS — Swift Package Manager
.package(url: "https://github.com/AY-Org/eb-ds-ios", from: "1.0.0")
Android — Gradle (Kotlin DSL)
implementation("com.eastblue.ds:form-elements:1.0.0")
Import
import EastBlueDS import com.eastblue.ds.form.*
Package not yet published. These are the planned distribution paths.
| Figma Property | SwiftUI | Compose |
|---|---|---|
| State = Default | Focused | Error | Disabled | .ebState(.default) · @FocusState · .ebError(true) · .disabled(true) | state = EBFieldState.Default · interactionSource · isError = true · enabled = false |
| hasValue = False | True | — derived from whether text is empty | — derived from whether value is empty |
| hasLabel (boolean) | .ebLabel(_ text: String?) | label: String? |
| hasSubtext (boolean) | .ebSubtext(_ text: String?) | subtext: String? |
| — no Figma property | text: Binding<String> | value: String + onValueChange: (String) -> Unit |
| — no Figma property | axis: .vertical | singleLine = false |
EBTextArea("Write your message…", text: $message) .ebState(.default) .ebLabel("Label") .ebSubtext("Message content")
EBTextArea( placeholder = "Write your message…", value = message, onValueChange = { message = it }, label = "Label", subtext = "Message content" )
EBTextArea("Write your message…", text: $message)
EBTextArea( placeholder = "Write your message…", value = message, onValueChange = { message = it }, label = null, subtext = null )
EBTextArea("Write your message…", text: $message) .ebState(.error)
EBTextArea( placeholder = "Write your message…", value = message, onValueChange = { message = it }, state = EBFieldState.Error )
EBTextArea("Write your message…", text: $message) .disabled(true)
EBTextArea( placeholder = "Write your message…", value = message, onValueChange = { message = it }, enabled = false )
| Requirement | iOS | Android |
|---|---|---|
| Minimum touch target | 44 x 44 pt (per-line height 22pt, container ≥44pt) | 48 x 48 dp |
| Accessibility label | .accessibilityLabel("Comment") | contentDescription |
| Error announcement | VoiceOver reads error via .accessibilityValue | TalkBack reads error via semantics { error() } |
| Character-count announcement | Announce remaining via .accessibilityValue when a limit is set | Expose via supportingText semantics |
Do
Use for free-form responses expected to exceed one line — feedback, comments, messages, notes.
Don't
Use for short structured inputs (name, phone, code) — Input Field's single-line default is more appropriate and faster to fill.
Do
Turn `hasSubtext` off when there is nothing useful to say — an empty subtext row still reserves 22px and pushes the layout down.
Don't
Turn `hasLabel` off to save space in a form — a field without a visible label leaves screen-reader users with only the placeholder, which disappears on focus.
Do
Pair with a visible label above the field and a helper-text row below for character counts or format hints.
Don't
Expect the field to be manually resizable — the desktop resize handle was removed in v2.2 because it had no touch equivalent. The field grows with its content within the platform's lineLimit / maxLines.
| ID | Criterion | Status | Notes |
|---|---|---|---|
| C1 | Layer Structure & Naming | Ready | Local layers follow the §3 vocabulary — ValueContainer → Value — and the nested duplicate text-container is gone. The remaining #label and #subtext layers belong to the shared FormGroup Header and Subtext Message instances and are their owner’s to rename. |
| C2 | Variant & Property Naming | Ready | State = Default | Focused | Error | Disabled × hasValue = False | True, PascalCase per §1 with Title Case values per §5. isFilled → hasValue and Active → Focused both landed. Error on the State axis is the documented §6 form-field exception, not a divergence. |
| C3 | Token Coverage | Needs Refinement | The component-scoped namespace was replaced by the shared generic token scale. Bindings themselves are not readable through the review tooling, so the token paths on the Style tab are indicative and need a Dev Mode confirmation. |
| C4 | Native Mappability | Ready | Confirmed a standalone component. Native platforms model it as a text field with axis: .vertical / singleLine = false rather than a separate primitive, which the property mapping now states. The desktop-only resize handle was removed — it had no touch equivalent. |
| C5 | Interaction State Coverage | Ready | All four interaction states ship, each with its own border treatment — 1px #D7E0EF default, 2px #005CE5 focused, 2px #D61B2C error, no border on disabled. Label, subtext and the character counter are all present as shared instances. |
| C6 | Asset & Icon Quality | Not Applicable | The component carries no icons. The raster resize glyph that previously appeared once per state was removed with the resize affordance in v2.2. |
State (4) × hasValue (2) — all eight combinations exist. The filled variants are 16px taller because the value wraps to two lines. hasLabel and hasSubtext are component properties rather than variant axes, so they do not multiply the count.
| State | hasValue | Dimensions | Node ID |
|---|---|---|---|
| Default | False | 358 × 78 | 4781:35853 |
| Default | True | 358 × 94 | 4781:35851 |
| Focused | False | 358 × 78 | 4781:35850 |
| Focused | True | 358 × 94 | 4781:35852 |
| Error | False | 358 × 78 | 4781:35849 |
| Error | True | 358 × 94 | 4781:35855 |
| Disabled | False | 358 × 78 | 4781:35848 |
| Disabled | True | 358 × 94 | 4781:35854 |
State and hasValue as selects (both are variant properties), hasLabel and hasSubtext as toggles. Replaces four fixed cards. DocumentedValue is 338 × 32 at line-height 16, so switching hasValue now grows the field and wraps the content, as it does in Figma. Caught by checking against an export rather than the layer tree. DocumentedstrokeWeight is not exposed by the plugin. DocumentedisFilled, isExpandable and state = active are gone; isExpandable never existed on the component. Now maps State, hasValue, hasLabel and hasSubtext plus the value binding and the multi-line behaviour. DocumentedisFilled axis renamed to hasValue, and heights corrected from a single 62px to 78px empty / 94px filled. Documentedtext-container → ValueContainer, #text-label → Value, so the entered text exposes as a single Value property. ResolvedhasValue settled as a family rule — an explicit boolean where the filled state changes geometry (78 → 94 here), derived in code where it changes only colour, as on Search Field. Added to the §2 approved boolean catalog. ResolvedSubtext Message instance, so that belongs to the shared component rather than here. ResolvedhasValue in all eight variants. Resolved0/100 empty and 71/100 filled, matching the 71-character sample exactly. Previously the count and the content disagreed, making the counter look decorative rather than bound. ResolvedisFilled → hasValue — has is the §2 prefix for a content-presence flag. Resolvedtext-container removed. ResolvedState=Error exception confirmed — covered by the §6 form-field exception in the Property Naming Guidelines. ResolvedState=Active renamed Focused — matching the sibling fields. ResolvedFormGroup Header and Subtext Message instances rather than redrawn locally. Resolvedstate (default/active/error/disabled) × isFilled (yes/no). Multi-line sibling of Input Field within the Form Elements group.
Documentedmultiline / lineLimit prop to match SwiftUI axis: .vertical and Compose singleLine=false.
OpenisFilled=yes/no instead of true/false. Same anti-pattern Input Field already resolved.
Openmain/text-area/color/* values mirror main/input-field/color/* exactly; candidate for aliasing or deletion after consolidation.
Open