Comic Textarea
A comic-book textarea: a speech bubble to write in, with halftone dots and a tail.
betaComicTextarea.tsx
Examples
Basic
comic-textarea/basic.tsx
import { ComicTextarea } from "@streamuilib/react";
export default function ComicTextareaBasic() {
return (
<label className="flex w-full max-w-md flex-col gap-2">
<span className="font-bold">Say something!</span>
<ComicTextarea placeholder="What should we build next?" />
</label>
);
}Invalid, and without the tail
comic-textarea/states.tsx
import { ComicTextarea } from "@streamuilib/react";
export default function ComicTextareaStates() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<label className="flex flex-col gap-2">
<span className="font-bold">Bio</span>
<ComicTextarea rows={2} invalid defaultValue="hi" aria-describedby="comic-bio-error" />
<span id="comic-bio-error" className="text-sm font-semibold">
Tell us a little more: at least 20 characters.
</span>
</label>
<label className="flex flex-col gap-2">
<span className="font-bold">Notes, no tail</span>
<ComicTextarea rows={2} tail={false} placeholder="A plain comic box" />
</label>
</div>
);
}Installation
bun add @streamuilib/reactusage.tsx
import { ComicTextarea } from "@streamuilib/react";
<label htmlFor="msg">Say something!</label>
<ComicTextarea id="msg" />API
| Prop | Type | Default | Description |
|---|---|---|---|
| tail | boolean | true | The speech-bubble tail off the bottom left. |
| invalid | boolean | false | Sets aria-invalid, tints the frame pink, and slaps on the sticker. |
| sfx | ReactNode | false | "Oops!" | The sticker shown when invalid; false for none. |
| frameClassName | string | — | Classes for the outer frame (width, tilt). |
| rows | number | 4 | Visible lines. |
| ...props | ComponentProps<"textarea"> | — | Every native textarea attribute. |
Accessibility
- A real <textarea>: pair it with a <label>, and link error text with aria-describedby.
- The tail and sticker are decorative and hidden.
Source
packages/react/src/ComicTextarea.tsx
import type { ReactNode } from "react";
import { cn } from "./cn";
import { comicFrame, halftone, stampClass } from "./comic";
import type { TextareaProps } from "./Textarea";
export type ComicTextareaProps = TextareaProps & {
/** The sticker slapped on when invalid. Defaults to "Oops!"; false for none. */
sfx?: ReactNode | false;
/** A speech-bubble tail off the bottom left. */
tail?: boolean;
};
/**
* A comic-book textarea: a speech bubble to write in, with a thick ink frame,
* uneven corners, halftone dots, a tail, and a hard shadow that lifts on
* focus. When invalid it tints pink and an "Oops!" sticker slams on.
*/
export function ComicTextarea({
invalid,
sfx = "Oops!",
tail = true,
frameClassName,
className,
rows = 4,
...props
}: ComicTextareaProps) {
return (
<div className={cn("relative", tail && "pb-4")}>
<div className={comicFrame({ invalid, className: frameClassName })} style={halftone(10)}>
<textarea
rows={rows}
aria-invalid={invalid || undefined}
className={cn(
"w-full min-w-0 resize-y bg-transparent px-3.5 py-3 leading-relaxed font-medium placeholder:font-normal placeholder:text-ink-muted focus:outline-none disabled:cursor-not-allowed",
className,
)}
{...props}
/>
{invalid && sfx && (
<span aria-hidden className={stampClass}>
{sfx}
</span>
)}
</div>
{/* The tail: an ink triangle with the fill on top, poking out of the bottom border. */}
{tail && (
<>
<span aria-hidden className="absolute bottom-[1px] left-7 block size-0 border-t-[18px] border-r-[18px] border-t-ink border-r-transparent" />
<span
aria-hidden
className={cn(
"absolute bottom-[7px] left-[31.5px] block size-0 border-t-[11px] border-r-[11px] border-r-transparent",
invalid ? "border-t-pink-soft" : "border-t-paper-raised",
)}
/>
</>
)}
</div>
);
}