Textarea
A multi-line text input field for longer form content.
Basic Usage
A simple textarea with placeholder text.
With Default Value
A textarea with pre-filled content.
Sizes
Textarea comes in three sizes: small, default, and large.
Small
Default
Large
Disabled State
A textarea that cannot be edited.
With Label
Using the Label component with Textarea.
Form Example
Textarea in a form context with labels and descriptions.
With Character Limits
Using minlength and maxlength attributes.
Readonly
A textarea that displays content but cannot be edited.
Custom Class
Adding custom styling via the class prop.
FreeTextarea
Multi-line text input.
Install with the Proa CLI
$ proa ui add textareaInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Textarea recipe - type-safe styling system for Textarea component
//!
//! Provides size variants and base styling for the Textarea component.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Base styles for the textarea - similar to Input but with textarea-specific adjustments
mod base {
use super::*;
pub const TEXTAREA_BASE: &str = tw_join!(
// Layout
"flex w-full",
"min-h-16",
"field-sizing-content",
// Typography
"text-base md:text-sm",
Textarea source
Browse and copy the reviewed source included with this Free component.
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Markdown rendering for the textarea component.
use super::Textarea;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// Textarea renders its value — as a fenced code block when multiline,
/// as plain text otherwise. With no value, the placeholder is shown in
/// brackets (matching Input's convention).
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for Textarea {
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
if let Some(value) = self.value {
if value
.as_bytes()
.iter()
.any(|byte| matches!(byte, b'\r' | b'\n'))
{
cx.md_fenced_code("", value)?;
} else {
cx.write_md_text(value)?;
}
} else if let Some(placeholder) = self.placeholder {
md_sync! { "[" { placeholder } "]" }.render_md(cx)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn textarea_renders_placeholder() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Textarea {
placeholder: Some("Type your message here..."),
..Textarea::new()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
"[Type your message here\\.\\.\\.]"
);
}
#[test]
fn textarea_single_line_value_is_plain_text() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Textarea {
value: Some("Initial content"),
..Textarea::new()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "Initial content");
}
#[test]
fn textarea_multiline_value_is_fenced() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Textarea {
value: Some("line one\nline two"),
..Textarea::new()
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.starts_with("```\nline one\nline two\n```\n"),
"output: {output}"
);
}
/// A multiline textarea composed after mid-line prose must open its
/// fence at a blank-line boundary.
#[test]
fn textarea_multiline_after_prose_starts_new_block() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
Textarea {
value: Some("line one\nline two"),
..Textarea::new()
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("intro prose\n\n```\nline one"),
"output: {output}"
);
}
#[test]
fn textarea_empty_renders_nothing() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Textarea::new().render_md(&mut cx).unwrap();
assert!(String::from_utf8(buf).unwrap().is_empty());
}
#[test]
fn textarea_payload_cannot_close_its_fence() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Textarea {
value: Some("before\n```\n- injected list\nafter"),
..Textarea::new()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
"````\nbefore\n```\n- injected list\nafter\n````\n\n"
);
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
//! Textarea component - form multi-line text input field
//!
//! The Textarea component provides a styled multi-line text input with support for
//! various sizes, validation states, and form integration via proa_ir.
mod md;
mod recipe;
mod textarea;
// Re-export textarea types
pub use textarea::{Textarea, TextareaWrap};
// Re-export recipe types
pub use recipe::{
textarea_base_classes, textarea_classes, textarea_recipe, TextareaRecipe, TextareaRecipeProps,
TextareaSize, TextareaStyle, TEXTAREA_RECIPE,
};
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Textarea recipe - type-safe styling system for Textarea component
//!
//! Provides size variants and base styling for the Textarea component.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Base styles for the textarea - similar to Input but with textarea-specific adjustments
mod base {
use super::*;
pub const TEXTAREA_BASE: &str = tw_join!(
// Layout
"flex w-full",
"min-h-16",
"field-sizing-content",
// Typography
"text-base md:text-sm",
// Colors
"bg-transparent",
"text-foreground",
"placeholder:text-muted-foreground",
// Border (shadcn input style)
"border border-input",
"rounded-md",
// Shadow
"shadow-xs",
// Padding
"px-3 py-2",
// Focus visible (shadcn style)
"focus-visible:outline-hidden",
"focus-visible:border-ring",
"focus-visible:ring-ring/50",
"focus-visible:ring-[3px]",
// Disabled
"disabled:cursor-not-allowed",
"disabled:opacity-50",
// Selection
"selection:bg-primary",
"selection:text-primary-foreground",
// Aria invalid (error state)
"aria-invalid:border-destructive",
"aria-invalid:ring-destructive/20",
"dark:aria-invalid:ring-destructive/40"
);
}
/// Size variants for the textarea
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum TextareaSize {
Sm,
#[default]
Default,
Lg,
}
impl TextareaSize {
/// Get the Tailwind classes for this size
pub const fn classes(self) -> &'static str {
match self {
TextareaSize::Sm => "min-h-16 px-2.5 py-1.5 text-xs",
TextareaSize::Default => "min-h-20 px-3 py-2",
TextareaSize::Lg => "min-h-32 px-4 py-3 text-base",
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Sm => "sm",
Self::Default => "default",
Self::Lg => "lg",
}
}
}
/// Get textarea base classes
pub const fn textarea_base_classes() -> &'static str {
base::TEXTAREA_BASE
}
/// Optional inputs accepted by [`TextareaRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TextareaRecipeProps {
pub size: Option<TextareaSize>,
}
/// Compile-time Textarea recipe.
#[derive(Debug, Clone, Copy)]
pub struct TextareaRecipe {
root: &'static str,
size_default: TextareaSize,
}
impl TextareaRecipe {
pub const fn resolve(&'static self, props: TextareaRecipeProps) -> TextareaStyle {
let size = match props.size {
Some(size) => size,
None => self.size_default,
};
TextareaStyle { recipe: self, size }
}
}
/// Fully resolved, streamable Textarea style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct TextareaStyle {
recipe: &'static TextareaRecipe,
size: TextareaSize,
}
impl TextareaStyle {
pub const fn size(self) -> TextareaSize {
self.size
}
}
impl AttrValue for TextareaStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.size.classes().as_bytes())
}
}
pub const TEXTAREA_RECIPE: TextareaRecipe = TextareaRecipe {
root: base::TEXTAREA_BASE,
size_default: TextareaSize::Default,
};
pub const fn textarea_recipe() -> &'static TextareaRecipe {
&TEXTAREA_RECIPE
}
/// Compatibility resolver for callers of the former helper.
pub const fn textarea_classes(size: TextareaSize) -> TextareaStyle {
TEXTAREA_RECIPE.resolve(TextareaRecipeProps { size: Some(size) })
}
impl crate::variant_spec::ComponentSpec for TextareaRecipe {
const NAME: &'static str = "Textarea";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<TextareaSize>()]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn textarea_base_has_correct_classes() {
let classes = textarea_base_classes();
assert!(classes.contains("flex"));
assert!(classes.contains("rounded-md"));
assert!(classes.contains("border"));
assert!(classes.contains("text-foreground"));
}
#[test]
fn textarea_sizes() {
assert!(TextareaSize::Sm.classes().contains("min-h-16"));
assert!(TextareaSize::Default.classes().contains("min-h-20"));
assert!(TextareaSize::Lg.classes().contains("min-h-32"));
}
#[test]
fn recipe_resolves_defaults_and_overrides() {
let default = textarea_recipe().resolve(TextareaRecipeProps::default());
assert_eq!(default.size(), TextareaSize::Default);
let large = textarea_recipe().resolve(TextareaRecipeProps {
size: Some(TextareaSize::Lg),
});
assert_eq!(large.size(), TextareaSize::Lg);
}
}
textarea.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Textarea component implementation
#![allow(unused_must_use)]
use super::recipe::{TextareaRecipeProps, TextareaSize, TEXTAREA_RECIPE};
use crate::free_web::{binding_to_js_attr, JsonAttr};
#[cfg(test)]
use proa_core::ctx::Ctx;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_ir::{FieldRegistration, ReactiveAttr};
use proa_macros::html_sync;
/// Type-safe Textarea component.
///
/// A form textarea field with support for various sizes and states.
/// Follows the shadcn/ui textarea design with Tailwind CSS styling.
///
/// # Example
///
/// ```ignore
/// use proa_ui::{Textarea, TextareaSize};
/// use proa_macros::html_sync;
///
/// // Basic usage
/// html_sync! {
/// <div>
/// {Textarea {
/// placeholder: Some("Type your message here..."),
/// size: Some(TextareaSize::Default),
/// ..Textarea::new()
/// }}
/// </div>
/// }.render(cx);
///
/// // With form registration
/// let form = ContactForm::form();
/// html_sync! {
/// <div>
/// {Textarea {
/// registration: Some(form.register_message()),
/// placeholder: Some("Your message"),
/// aria_invalid: form.field_message().has_error(),
/// ..Textarea::new()
/// }}
/// </div>
/// }.render(cx);
/// ```
pub struct Textarea {
pub size: Option<TextareaSize>,
pub name: Option<&'static str>,
pub value: Option<&'static str>,
pub placeholder: Option<&'static str>,
pub disabled: ReactiveAttr<bool>,
pub required: bool,
pub readonly: bool,
pub autofocus: bool,
pub rows: Option<&'static str>,
pub cols: Option<&'static str>,
pub minlength: Option<&'static str>,
pub maxlength: Option<&'static str>,
pub wrap: Option<TextareaWrap>,
pub aria_label: Option<&'static str>,
pub aria_describedby: Option<&'static str>,
pub aria_invalid: ReactiveAttr<bool>,
/// Field registration data (from form.register_*)
pub registration: Option<FieldRegistration>,
pub class: Option<&'static str>,
pub id: Option<&'static str>,
}
/// Wrap mode for textarea
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum TextareaWrap {
#[default]
Soft,
Hard,
Off,
}
impl TextareaWrap {
/// Get the HTML wrap attribute value
pub const fn as_str(self) -> &'static str {
match self {
TextareaWrap::Soft => "soft",
TextareaWrap::Hard => "hard",
TextareaWrap::Off => "off",
}
}
}
impl Textarea {
/// Create a new textarea with default settings
pub const fn new() -> Self {
Self {
size: None,
name: None,
value: None,
placeholder: None,
disabled: ReactiveAttr::static_value(false),
required: false,
readonly: false,
autofocus: false,
rows: None,
cols: None,
minlength: None,
maxlength: None,
wrap: None,
aria_label: None,
aria_describedby: None,
aria_invalid: ReactiveAttr::static_value(false),
registration: None,
class: None,
id: None,
}
}
}
impl Default for Textarea {
fn default() -> Self {
Self::new()
}
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for Textarea {
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = TEXTAREA_RECIPE.resolve(TextareaRecipeProps { size: self.size });
let is_disabled = self.disabled.default;
let is_aria_invalid = self.aria_invalid.default;
let disabled_binding_string = binding_to_js_attr(&self.disabled.binding);
let aria_invalid_binding_string = binding_to_js_attr(&self.aria_invalid.binding);
let disabled_binding = disabled_binding_string.as_deref();
let aria_invalid_binding = aria_invalid_binding_string.as_deref();
let registration = self.registration.as_ref();
let rules_attr = JsonAttr(registration.map(|reg| reg.rules.as_slice()));
let data_field = registration.map(|reg| reg.data_field);
let name_attr = registration.map(|reg| reg.name).or(self.name);
let validate_on = registration.map(|reg| match reg.validate_on {
proa_ir::ValidationTrigger::Blur => "blur",
proa_ir::ValidationTrigger::Input => "input",
proa_ir::ValidationTrigger::Change => "change",
proa_ir::ValidationTrigger::Submit => "submit",
});
let wrap_attr = self.wrap.map(|w| w.as_str());
html_sync! {
<textarea
class={proa_macros::text!("{} {}", style, self.class)}
id={self.id}
name={name_attr}
placeholder={self.placeholder}
disabled={is_disabled}
required={self.required}
readonly={self.readonly}
autofocus={self.autofocus}
rows={self.rows}
cols={self.cols}
minlength={self.minlength}
maxlength={self.maxlength}
wrap={wrap_attr}
aria-label={self.aria_label}
aria-describedby={self.aria_describedby}
aria-invalid={is_aria_invalid}
data-textarea
data-slot="textarea"
data-size={style.size().as_str()}
data-field={data_field}
data-rules={rules_attr}
data-validate-on={validate_on}
data-bind-disabled={disabled_binding}
data-bind-aria-invalid={aria_invalid_binding}
>
{proa_core::raw_text_ok(self.value.unwrap_or(""))}
</textarea>
}
.render(cx)
}
}
/// Convert a Binding to a JavaScript expression string
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn textarea_renders_with_defaults() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Textarea::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<textarea"));
assert!(html.contains("</textarea>"));
assert!(html.contains("class=\""));
assert!(html.contains("min-h-20"));
assert!(html.contains("data-slot=\"textarea\""));
assert!(html.contains("data-size=\"default\""));
}
#[test]
fn textarea_renders_with_placeholder() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Textarea {
placeholder: Some("Type your message..."),
..Textarea::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("placeholder=\"Type your message...\""));
}
#[test]
fn textarea_renders_with_value() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Textarea {
value: Some("Initial content"),
..Textarea::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("Initial content"));
}
#[test]
fn textarea_applies_sizes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Textarea {
size: Some(TextareaSize::Lg),
..Textarea::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("min-h-32"));
}
#[test]
fn textarea_handles_disabled_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Textarea {
disabled: true.into(),
..Textarea::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(" disabled"));
}
#[test]
fn textarea_handles_required_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Textarea {
required: true,
..Textarea::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(" required"));
}
#[test]
fn textarea_renders_with_rows_and_cols() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Textarea {
rows: Some("5"),
cols: Some("40"),
..Textarea::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("rows=\"5\""));
assert!(html.contains("cols=\"40\""));
}
#[test]
fn textarea_renders_aria_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Textarea {
aria_label: Some("Message"),
aria_describedby: Some("message-help"),
aria_invalid: true.into(),
..Textarea::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("aria-label=\"Message\""));
assert!(html.contains("aria-describedby=\"message-help\""));
assert!(html.contains("aria-invalid"));
}
#[test]
fn textarea_renders_with_custom_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Textarea {
class: Some("custom-textarea"),
..Textarea::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("custom-textarea"));
}
#[test]
fn textarea_wrap_as_str() {
assert_eq!(TextareaWrap::Soft.as_str(), "soft");
assert_eq!(TextareaWrap::Hard.as_str(), "hard");
assert_eq!(TextareaWrap::Off.as_str(), "off");
}
#[test]
fn textarea_size_is_reasonable() {
assert!(std::mem::size_of::<Textarea>() < 512);
const _TEXTAREA: Textarea = Textarea::new();
}
}