stream/ui
Browse the library

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/react
Open in v0 (opens in a new tab)
usage.tsx
import { ComicTextarea } from "@streamuilib/react";

<label htmlFor="msg">Say something!</label>
<ComicTextarea id="msg" />

API

PropTypeDefaultDescription
tailbooleantrueThe speech-bubble tail off the bottom left.
invalidbooleanfalseSets aria-invalid, tints the frame pink, and slaps on the sticker.
sfxReactNode | false"Oops!"The sticker shown when invalid; false for none.
frameClassNamestring—Classes for the outer frame (width, tilt).
rowsnumber4Visible lines.
...propsComponentProps<"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>
  );
}