Component
Textarea With Actions
Textarea with a contextual action bar — live character count plus real Clear and Copy buttons.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/textareas/textarea-with-actions/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Textareas/textarea-with-actions React/Components/Textareas/textarea-with-actions import type { ChangeEvent, ReactNode, TextareaHTMLAttributes } from "react";
import { useCallback, useId, useRef, useState } from "react";
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
const TEXTAREA_BASE =
"w-full min-h-[80px] resize-y rounded-[var(--ds-radius-sm)] border bg-[var(--ds-color-input)] px-3 py-2 text-sm leading-5 text-[var(--ds-color-foreground)] shadow-none transition-colors duration-150 ease-out placeholder:text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:bg-[var(--ds-color-input-focus,var(--ds-color-input))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:bg-[var(--ds-color-muted)] disabled:text-[var(--ds-color-muted-foreground)] disabled:opacity-60 read-only:bg-[var(--ds-color-surface-subtle)] read-only:text-[var(--ds-color-muted-foreground)] motion-reduce:transition-none";
const TEXTAREA_BORDER =
"border-[var(--ds-color-border)] focus:border-[var(--ds-color-border-strong)]";
const BUTTON_BASE =
"inline-flex h-8 select-none items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--ds-radius-sm)] border px-3 text-xs font-medium leading-none transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none [&_svg]:size-[14px]";
const BUTTON_GHOST =
"border-transparent bg-transparent text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)] active:bg-[var(--ds-color-surface-active)]";
const BUTTON_SECONDARY =
"border-[var(--ds-color-border)] bg-[var(--ds-color-secondary)] text-[var(--ds-color-secondary-foreground)] hover:bg-[var(--ds-color-surface-active)] active:bg-[var(--ds-color-surface-active)]";
function useCopy(resetMs: number): readonly [boolean, (text: string) => Promise<void>] {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const copy = useCallback(async (text: string) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), resetMs);
} catch { /* clipboard unavailable */ }
}, [resetMs]);
return [copied, copy] as const;
}
export interface TextareaWithActionsProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onCopy"> {
/** Visible label — required. */
label: ReactNode;
/** Accessible label for the clear action. */
clearLabel?: string;
/** Label for the copy action. */
copyLabel?: string;
/** Label shown after a successful copy. */
copiedLabel?: string;
/** Reset delay (ms) before the copy label returns to normal. */
resetMs?: number;
/** Called after the clear action runs. */
onClear?: () => void;
/** Called after the copy action runs. */
onCopy?: (value: string) => void;
}
/**
* Textarea with a contextual action bar: a live character count plus real
* Clear and Copy buttons. Clear empties the field (and returns focus to
* it); Copy writes the current value to the clipboard and confirms with a
* label swap + an `aria-live` status message. Both actions derive from the
* real value (controlled or uncontrolled), are disabled when the field is
* empty, use the DevSnips ghost/secondary button styles, and work fully
* from the keyboard. The action row wraps on narrow screens.
*/
export function TextareaWithActions({
label,
clearLabel = "Clear",
copyLabel = "Copy",
copiedLabel = "Copied",
resetMs = 2000,
onClear,
onCopy,
id,
className,
rows = 3,
value,
defaultValue = "",
onChange,
maxLength,
...props
}: TextareaWithActionsProps) {
const generatedId = useId();
const textareaId = id ?? `textarea-${generatedId}`;
const countId = `${textareaId}-count`;
const statusId = `${textareaId}-status`;
const [internalValue, setInternalValue] = useState(String(defaultValue ?? ""));
const currentValue = value === undefined ? internalValue : String(value ?? "");
const isEmpty = currentValue.length === 0;
const [copied, copy] = useCopy(resetMs);
function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {
if (value === undefined) setInternalValue(event.target.value);
onChange?.(event);
}
function handleClear() {
if (value === undefined) setInternalValue("");
onClear?.();
document.getElementById(textareaId)?.focus();
}
async function handleCopy() {
await copy(currentValue);
onCopy?.(currentValue);
}
return (
<div className="w-full">
<label
htmlFor={textareaId}
className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]"
>
{label}
</label>
<textarea
id={textareaId}
rows={rows}
value={currentValue}
onChange={handleChange}
maxLength={maxLength}
aria-describedby={countId}
className={cx(TEXTAREA_BASE, TEXTAREA_BORDER, className)}
{...props}
/>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<span
id={countId}
aria-live="polite"
className="text-xs leading-4 text-[var(--ds-color-muted-foreground)]"
>
{currentValue.length}{maxLength !== undefined ? ` / ${maxLength}` : " characters"}
</span>
<div className="flex items-center gap-2">
<button
type="button"
aria-label={clearLabel}
onClick={handleClear}
disabled={isEmpty}
className={cx(BUTTON_BASE, BUTTON_GHOST)}
>
{clearLabel}
</button>
<button
type="button"
aria-describedby={statusId}
onClick={handleCopy}
disabled={isEmpty}
className={cx(BUTTON_BASE, BUTTON_SECONDARY)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
{copied
? <path d="M20 6 9 17l-5-5" />
: <><rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>}
</svg>
<span>{copied ? copiedLabel : copyLabel}</span>
</button>
</div>
</div>
<span id={statusId} role="status" aria-live="polite" className="sr-only">
{copied ? copiedLabel : ""}
</span>
</div>
);
}
export default TextareaWithActions; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import { useCallback, useId, useRef, useState } from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const TEXTAREA_BASE = "w-full min-h-[80px] resize-y rounded-[var(--ds-radius-sm)] border bg-[var(--ds-color-input)] px-3 py-2 text-sm leading-5 text-[var(--ds-color-foreground)] shadow-none transition-colors duration-150 ease-out placeholder:text-[var(--ds-color-muted-foreground)] hover:bg-[var(--ds-color-input-hover,var(--ds-color-input))] focus:bg-[var(--ds-color-input-focus,var(--ds-color-input))] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:bg-[var(--ds-color-muted)] disabled:text-[var(--ds-color-muted-foreground)] disabled:opacity-60 read-only:bg-[var(--ds-color-surface-subtle)] read-only:text-[var(--ds-color-muted-foreground)] motion-reduce:transition-none";
const TEXTAREA_BORDER = "border-[var(--ds-color-border)] focus:border-[var(--ds-color-border-strong)]";
const BUTTON_BASE = "inline-flex h-8 select-none items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--ds-radius-sm)] border px-3 text-xs font-medium leading-none transition-colors duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none [&_svg]:size-[14px]";
const BUTTON_GHOST = "border-transparent bg-transparent text-[var(--ds-color-foreground)] hover:bg-[var(--ds-color-surface-hover)] active:bg-[var(--ds-color-surface-active)]";
const BUTTON_SECONDARY = "border-[var(--ds-color-border)] bg-[var(--ds-color-secondary)] text-[var(--ds-color-secondary-foreground)] hover:bg-[var(--ds-color-surface-active)] active:bg-[var(--ds-color-surface-active)]";
function useCopy(resetMs) {
const [copied, setCopied] = useState(false);
const timer = useRef(null);
const copy = useCallback(async (text) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), resetMs);
} catch {
}
}, [resetMs]);
return [copied, copy];
}
export function TextareaWithActions({
label,
clearLabel = "Clear",
copyLabel = "Copy",
copiedLabel = "Copied",
resetMs = 2e3,
onClear,
onCopy,
id,
className,
rows = 3,
value,
defaultValue = "",
onChange,
maxLength,
...props
}) {
const generatedId = useId();
const textareaId = id ?? `textarea-${generatedId}`;
const countId = `${textareaId}-count`;
const statusId = `${textareaId}-status`;
const [internalValue, setInternalValue] = useState(String(defaultValue ?? ""));
const currentValue = value === undefined ? internalValue : String(value ?? "");
const isEmpty = currentValue.length === 0;
const [copied, copy] = useCopy(resetMs);
function handleChange(event) {
if (value === undefined) setInternalValue(event.target.value);
onChange?.(event);
}
function handleClear() {
if (value === undefined) setInternalValue("");
onClear?.();
document.getElementById(textareaId)?.focus();
}
async function handleCopy() {
await copy(currentValue);
onCopy?.(currentValue);
}
return <div className="w-full">
<label
htmlFor={textareaId}
className="mb-2 block text-[13px] font-medium leading-5 text-[var(--ds-color-foreground)]"
>
{label}
</label>
<textarea
id={textareaId}
rows={rows}
value={currentValue}
onChange={handleChange}
maxLength={maxLength}
aria-describedby={countId}
className={cx(TEXTAREA_BASE, TEXTAREA_BORDER, className)}
{...props}
/>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<span
id={countId}
aria-live="polite"
className="text-xs leading-4 text-[var(--ds-color-muted-foreground)]"
>
{currentValue.length}{maxLength !== undefined ? ` / ${maxLength}` : " characters"}
</span>
<div className="flex items-center gap-2">
<button
type="button"
aria-label={clearLabel}
onClick={handleClear}
disabled={isEmpty}
className={cx(BUTTON_BASE, BUTTON_GHOST)}
>
{clearLabel}
</button>
<button
type="button"
aria-describedby={statusId}
onClick={handleCopy}
disabled={isEmpty}
className={cx(BUTTON_BASE, BUTTON_SECONDARY)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
{copied ? <path d="M20 6 9 17l-5-5" /> : <><rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></>}
</svg>
<span>{copied ? copiedLabel : copyLabel}</span>
</button>
</div>
</div>
<span id={statusId} role="status" aria-live="polite" className="sr-only">
{copied ? copiedLabel : ""}
</span>
</div>;
}
export default TextareaWithActions; # Textarea With Actions
Textarea with a contextual action bar — live character count plus real Clear and Copy buttons.
## Usage
```tsx
<TextareaWithActions label="Support reply" maxLength={500} onCopy={(v) => console.log(v)} />
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
<TextareaWithActions label="Support reply" maxLength={500} onCopy={(v) => console.log(v)} />
```
## Props
| Name | Type | Default | Description |
|---|---|---:|---|
| `label` | `ReactNode` (required) | — | Visible label above the control. |
| `value` / `defaultValue` | `string` | — | Controlled / uncontrolled value. |
| `onChange` | `(event) => void` | — | Native change callback. |
| `rows` | `number` | `3` | Visible rows — the natural height floor (with `min-h-[80px]`). |
| `placeholder` | `string` | — | Muted placeholder (never critical information). |
| `disabled` | `boolean` | — | Native disabled — not focusable, not submitted. |
| `readOnly` | `boolean` | — | Native read-only — focusable, selectable, submitted. |
| `required` / `name` / `id` | `boolean` / `string` | — | Native form semantics (`id` also the label `htmlFor`). |
| `minLength` / `maxLength` | `number` | — | Native length constraints. |
| `className` | `string` | — | Extra Tailwind classes merged onto the control. |
| other native props / `aria-*` | — | — | Passed through to the `<textarea>`. |
| `clearLabel` / `copyLabel` / `copiedLabel` | `string` | `Clear` / `Copy` / `Copied` | Action labels. |
| `resetMs` | `number` | `2000` | Delay before the copy label resets. |
| `onClear` / `onCopy` | `() => void` / `(value) => void` | — | Action callbacks. |
## States
Native textarea with an action bar below the field: a live character count on the left, and real Clear and Copy buttons on the right. Clear empties the value and returns focus to the field; Copy writes the current value to the clipboard (with a fallback for non-secure contexts) and confirms via a label swap + an `aria-live` status message. Both buttons disable while the field is empty, and both act on the real value in controlled and uncontrolled modes. The bar wraps on narrow screens.
## Accessibility
Both actions are real `<button type="button">` elements with visible text labels, keyboard-operable, with visible `focus-visible` rings. Copy feedback is announced through a `role="status"` / `aria-live="polite"` region; the count is `aria-describedby` + polite live. Clear returns focus to the textarea so keyboard users are not stranded.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-input)]`). Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This textarea uses the semantic color, radius, spacing, and motion tokens.
## Notes
Every action here has a real job — clearing drafts and copying composed text. Do not add icon buttons for decoration; extend the bar only with actions that operate on the value (e.g. a template insert or a formatting command). 175 lines UTF-8 · LF · Spaces: 2
Continue browsing