Patterns · Settings
Counted textarea
The multi-line settings field — a CountedInput that wraps to several lines, grows from minRows to maxRows then scrolls, with the same focus-only character counter.
Examples
Max length
Status
Focus a field — the counter appears only while focused; type past a few lines and it scrolls
States · no counter · disabled · readonly
<!-- Paste-and-run: save as .html and open in a browser. No build step.
<aha-counted-textarea> is the SAME shared custom element React and Vue consume — here in its
native form. Theming comes only from the --aha-* tokens in tokens.css. The character counter is
revealed only while the field is focused (SETTINGS-21/42) — click in to see it; type past a few
lines and the field scrolls instead of growing unbounded. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/tokens.css">
<script type="module">
import 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/aha-counted-textarea.js'; // registers <aha-counted-textarea>
</script>
<!-- maxlength turns the focus-only counter on; minrows/maxrows bound the autogrow -->
<aha-counted-textarea id="desc" maxlength="150" minrows="2" maxrows="5"
value="A longer answer that wraps to several lines." placeholder="Answer"></aha-counted-textarea>
<!-- status: error · disabled / readonly -->
<aha-counted-textarea status="error" maxlength="120" value="Too long" placeholder="Description"></aha-counted-textarea>
<script>
// Plain DOM — no framework. Read e.detail.value off the composed CustomEvent.
document.getElementById('desc').addEventListener('input', (e) => {
console.log('value:', e.detail.value);
});
</script>
import '@ahaslides-product/design/aha-counted-textarea'; // registers <aha-counted-textarea>
import { useRef, useEffect } from 'react';
// React 18 needs a thin wrapper; React 19 can use <aha-counted-textarea> directly.
// The counter shows only on focus when maxLength is set; the field grows minRows→maxRows then scrolls.
function AhaCountedTextarea({ value, placeholder, maxLength, minRows, maxRows, status, disabled, readOnly, onChange }) {
const ref = useRef();
useEffect(() => {
const el = ref.current;
el.value = value ?? '';
el.disabled = !!disabled;
const h = (e) => onChange?.(e.detail.value); // composed CustomEvent
el.addEventListener('input', h);
return () => el.removeEventListener('input', h);
});
return (
<aha-counted-textarea
ref={ref}
placeholder={placeholder}
{...(maxLength ? { maxlength: String(maxLength) } : {})}
{...(minRows ? { minrows: String(minRows) } : {})}
{...(maxRows ? { maxrows: String(maxRows) } : {})}
{...(status ? { status } : {})}
{...(readOnly ? { readonly: '' } : {})}
/>
);
}
// usage
<AhaCountedTextarea value={answer} placeholder="Answer" maxLength={150} minRows={2} maxRows={5} onChange={setAnswer} />
// main.ts — register the element + mark aha-* as custom elements
import '@ahaslides-product/design/aha-counted-textarea'; // registers <aha-counted-textarea>
app.config.compilerOptions.isCustomElement = (tag) => tag.startsWith('aha-');
// Component.vue
<script setup>
import { ref } from 'vue';
const answer = ref('');
</script>
<template>
<!-- maxlength turns the focus-only counter on; minrows/maxrows bound the autogrow -->
<aha-counted-textarea
maxlength="150"
minrows="2"
maxrows="5"
placeholder="Answer"
:value.prop="answer"
@input="answer = $event.detail.value"
/>
</template>
API
| Prop | Type | Default | Notes |
|---|---|---|---|
value | string | "" | Controlled text value |
placeholder | string | — | Hint shown when empty |
maxlength | number | 150 | Character cap (settings-lab default 150); drives the focus-only counter (shows current/max). Pass `none` to drop the cap and hide the counter |
minrows | number | 2 | Rows shown at rest — the field never shrinks below this |
maxrows | number | 5 | Rows the field grows to before it scrolls internally instead of growing |
status | default | error | default | Error recolours the border + ring and sets aria-invalid |
disabled | boolean | false | Non-interactive; grey fill |
readonly | boolean | false | Selectable but not editable; secondary fill |
borderless | boolean | false | Drop the field's own border/ring/fill — for composing inside a parent that carries the one border (OptionRow, SETTINGS-34) |
input | CustomEvent<{value}> | — | Composed event on each keystroke; read e.detail.value |
change | CustomEvent<{value}> | — | Composed event on commit (blur) |
Install
# .npmrc — once: point the @ahaslides-product scope at GitHub Packages
@ahaslides-product:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} # a GitHub token with read:packages
npm i @ahaslides-product/design
import '@ahaslides-product/design/tokens.css'; // once, at the app root
import '@ahaslides-product/design/aha-counted-textarea'; // registers <aha-counted-textarea>
Agent feed for this component (absolute, fetchable anywhere): counted-textarea.agent.json · counted-textarea.md · counted-textarea.llms.txt
When to use
When to use
- Counted textarea — a multi-line settings field — a description, a long answer — that needs a character cap; the counter appears only on focus
- Counted input — the single-line sibling — option/answer/label text with a cap
- Input — a general single-line field with no counter (names, search)
Never show a permanent counter on every field — it reads as heavy. The counter belongs inside the field, revealed on focus. Cap the growth: a textarea that grows without bound pushes the rest of the panel down; grow to maxRows then scroll.
Surfaces
editor settings
Spec
Box radius 8 · padding 6 12 · resize off · Autogrow minrows (default 2) → maxrows (default 5); beyond maxrows scrolls internally, never grows unbounded · Rest border 1px #E3E3E3 · Focus #6A1EBB border + 2px #D3B4FF@30% ring (on the persistent wrapper) · Counter 12/18 #8A8A8A, overlay bottom-right; opacity 0 at rest → 1 on focus · Reveal focus-only (:host([_focused][_counted])) — hidden at rest; space always reserved (no shift) · Error #F5222D border + error-tinted ring; sets aria-invalid · Disabled #F1F1F1 fill, #EBEBEB border, not-allowed · Text Plus Jakarta 14/21; placeholder #8A8A8A