MC Input

A single-component text input with field integration, addon slots, and error handling.

Overview

The mc-input is a single-component text input for the MicroClub UI library. Built on the @base-ui/react input primitive, it provides a production-ready field with label, description, error handling, and four addon slots out of the box.

As a part of the MicroClub library, this component is designed for instant deployment into your codebase, giving you full ownership of its look and behavior.

Preview

Loading...
import { McInput } from '../ui/mc-input';export default function McInputDemo() {  return (    <McInput      label="Email"      description="This is a hint text to help user."      placeholder="olivia@untitledui.com"    />  );}

Add to Your Project

Deploy the mc-input directly to your components/ui directory:

npx mcoli-ui@latest add mc-input
pnpm dlx mcoli-ui@latest add mc-input
yarn dlx mcoli-ui@latest add mc-input
bunx mcoli-ui@latest add mc-input

Manual Setup

If you prefer manual control:

Install the core dependencies
npm install @base-ui/react
pnpm add @base-ui/react
yarn add @base-ui/react
bun add @base-ui/react
Copy the source code
'use client';import * as React from 'react';import { Input as InputPrimitive } from '@base-ui/react/input';import { cn } from '@/lib/utils';export interface McInputProps extends React.ComponentProps<'input'> {  /** Label rendered above the input. */  label?: React.ReactNode;  /** Help text below the input. Replaced by error when present. */  description?: React.ReactNode;  /** Truthy = error state. ReactNode = renders the message. `true` = red border only. */  error?: React.ReactNode;  /** Inline-start addon (icons, text, dropdowns). */  addonStart?: React.ReactNode;  /** Inline-end addon (buttons, badges, spinners). */  addonEnd?: React.ReactNode;  /** Block-start addon (toolbar, controls above the input row). */  addonTop?: React.ReactNode;  /** Block-end addon (character count, status bar below the input row). */  addonBottom?: React.ReactNode;}function McInput({  className,  label,  description,  error,  addonStart,  addonEnd,  addonTop,  addonBottom,  type,  id,  disabled,  required,  ...props}: McInputProps) {  const generatedId = React.useId();  const inputId = id ?? generatedId;  const hasError = !!error;  const errorMessage = typeof error === 'boolean' ? null : error;  const showError = hasError && !!errorMessage;  const showDescription = !hasError && !!description;  const messageId = showError    ? `${inputId}-error`    : showDescription      ? `${inputId}-description`      : undefined;  const handleGroupClick = (e: React.MouseEvent<HTMLDivElement>) => {    const target = e.target as HTMLElement;    if (!target.closest('button, a, input, select, textarea, [tabindex]')) {      e.currentTarget.querySelector<HTMLInputElement>('[data-slot="input"]')?.focus();    }  };  return (    <div data-slot="input-root" className={cn('flex flex-col gap-1.5', className)}>      {label && (        <label          data-slot="input-label"          htmlFor={inputId}          className="text-sm font-medium text-muted-foreground"        >          {label}        </label>      )}      <div        data-slot="input-group"        data-error={hasError || undefined}        data-disabled={disabled || undefined}        data-has-top={!!addonTop || undefined}        data-has-bottom={!!addonBottom || undefined}        role="group"        onClick={handleGroupClick}        className={cn(          // Structure          'group/input flex min-w-0 cursor-text flex-col rounded-lg border border-border bg-input shadow-xs transition-all',          // Focus ring          'focus-within:ring-4 focus-within:ring-ring',          // Error          'data-error:border-destructive',          'data-error:focus-within:border-destructive data-error:focus-within:ring-destructive/20',          'dark:data-error:border-destructive/60 dark:data-error:focus-within:ring-destructive/30',          // Disabled          'data-disabled:pointer-events-none data-disabled:opacity-50'        )}      >        {addonTop && (          <div            data-slot="input-addon"            data-align="top"            className={cn(              'flex w-full items-center gap-2 border-b border-border px-3.5 py-1.5 text-sm text-muted-foreground select-none',              "[&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"            )}          >            {addonTop}          </div>        )}        {/*          has-data-attached:items-stretch — When an attached McInputButton is          anywhere inside this row, switch from center to stretch so the addon          container fills the full row height and the button can match it.        */}        <div data-slot="input-row" className="flex items-center has-data-attached:items-stretch">          {addonStart && (            <div              data-slot="input-addon"              data-align="start"              className={cn(                // group/addon — lets attached buttons detect start/end via group-data-[align]/addon                // :has(>svg:first-child):pl-3.5 — widen padding only when the edge element is an icon                // has-data-attached:pl-0 — flush for attached buttons (attribute selector beats type selector)                // has-data-attached:items-stretch — let attached button fill full height                'group/addon flex shrink-0 items-center gap-2 pl-2 text-sm text-muted-foreground select-none [&:has(>svg:first-child)]:pl-3.5 has-data-attached:items-stretch has-data-attached:pl-0',                "[&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"              )}            >              {addonStart}            </div>          )}          <InputPrimitive            data-slot="input"            type={type}            id={inputId}            disabled={disabled}            required={required}            aria-invalid={hasError || undefined}            aria-describedby={messageId}            className={cn(              'h-10 flex-1 min-w-0 bg-transparent px-3.5 py-2.5 text-sm outline-none placeholder:text-muted-foreground file:inline-flex file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground disabled:cursor-not-allowed',              // Hide number input spinners across all browsers              '[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',              addonStart && 'pl-2',              addonEnd && 'pr-2'            )}            {...props}          />          {addonEnd && (            <div              data-slot="input-addon"              data-align="end"              className={cn(                // Same as addonStart, mirrored (pl → pr, first-child → last-child)                'group/addon flex shrink-0 items-center gap-2 pr-2 text-sm text-muted-foreground select-none [&:has(>svg:last-child)]:pr-3.5 has-data-attached:items-stretch has-data-attached:pr-0',                "[&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"              )}            >              {addonEnd}            </div>          )}        </div>        {addonBottom && (          <div            data-slot="input-addon"            data-align="bottom"            className={cn(              'flex w-full items-center gap-2 border-t border-border px-3.5 py-1.5 text-sm text-muted-foreground select-none',              "[&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"            )}          >            {addonBottom}          </div>        )}      </div>      {showError && (        <p          data-slot="input-error"          id={`${inputId}-error`}          role="alert"          className="text-sm text-destructive"        >          {errorMessage}        </p>      )}      {showDescription && (        <p          data-slot="input-description"          id={`${inputId}-description`}          className="text-sm text-muted-foreground"        >          {description}        </p>      )}    </div>  );}export interface McInputButtonProps extends React.ComponentProps<'button'> {  /** Visual style of the button. `attached` extends to the input edge and shares its border. */  variant?: 'ghost' | 'outline' | 'filled' | 'attached';}function McInputButton({  className,  variant = 'ghost',  type = 'button',  ...props}: McInputButtonProps) {  const isAttached = variant === 'attached';  return (    <button      type={type}      data-slot="input-button"      data-attached={isAttached || undefined}      className={cn(        // Base        'inline-flex cursor-pointer items-center justify-center gap-1.5 text-sm font-medium whitespace-nowrap transition-colors outline-none select-none disabled:pointer-events-none disabled:opacity-50',        "[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",        // Ghost        variant === 'ghost' &&          'h-7 rounded-md px-2 bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground',        // Outline        variant === 'outline' &&          'h-7 rounded-md px-3 border border-border bg-transparent text-foreground hover:bg-accent',        // Filled        variant === 'filled' &&          'h-7 rounded-md px-3 bg-primary text-primary-foreground hover:bg-primary/90',        // Attached — flush with input border, auto-detects position via group/addon.        // Radius is rounded-lg minus 1px to sit inside the group's border.        isAttached && [          'h-full px-4 bg-primary text-primary-foreground hover:bg-primary/90 border-border',          'group-data-[align=end]/addon:rounded-l-none group-data-[align=end]/addon:rounded-r-[calc(0.5rem-1px)] group-data-[align=end]/addon:border-l',          'group-data-[align=start]/addon:rounded-r-none group-data-[align=start]/addon:rounded-l-[calc(0.5rem-1px)] group-data-[align=start]/addon:border-r',          // Flatten corners adjacent to block addons (addonTop / addonBottom)          '[[data-has-top]_[data-align]_&]:rounded-tl-none [[data-has-top]_[data-align]_&]:rounded-tr-none',          '[[data-has-bottom]_[data-align]_&]:rounded-bl-none [[data-has-bottom]_[data-align]_&]:rounded-br-none',        ],        className      )}      {...props}    />  );}export { McInput, McInputButton };
Adjust import paths

Ensure your @/lib/utils or equivalent classname helper is correctly imported.

Usage

import { McInput } from '@/components/ui/mc-input';

export default function Example() {
  return <McInput label="Email" placeholder="olivia@example.com" />;
}

Examples

With Description

<McInput
  label="Email"
  description="We'll never share your email."
  placeholder="olivia@example.com"
/>

Error State

The error prop controls both the visual state and the message. When present, it replaces the description.

<McInput
  label="Email"
  error="Please enter a valid email address."
  placeholder="olivia@example.com"
/>

Icon Addons

import { Search, Mail } from 'lucide-react';

<McInput label="Search" addonStart={<Search />} placeholder="Search..." />
<McInput label="Email" addonStart={<Mail />} placeholder="olivia@example.com" />

Text Addons

<McInput label="Price" addonStart="$" addonEnd="USD" placeholder="0.00" type="number" />
<McInput label="Website" addonStart="https://" addonEnd=".com" placeholder="yoursite" />

McInputButton

McInputButton is purpose-built for input addon slots. It has four variants:

  • ghost — transparent, for icon toggles
  • outline — bordered, for secondary actions
  • filled — solid background, for primary actions
  • attached — full-height, shares the input border, auto-detects start/end position
import { McInput, McInputButton } from '@/components/ui/mc-input';
import { Search } from 'lucide-react';

<McInput
  label="Search"
  addonStart={<Search />}
  addonEnd={<McInputButton variant="attached">Search</McInputButton>}
  placeholder="Search anything..."
/>;

Block Addons

Use addonTop and addonBottom for toolbars, counters, and status bars.

<McInput
  label="Amount"
  addonTop={
    <div className="flex w-full items-center justify-between">
      <span className="text-xs font-medium">Balance: $1,234.56</span>
      <button className="text-xs font-medium text-primary hover:underline">Max</button>
    </div>
  }
  addonStart={<DollarSign />}
  placeholder="0.00"
  type="number"
/>

API Reference

McInput Props

PropTypeDefaultDescription
labelReactNodeundefinedLabel rendered above the input
descriptionReactNodeundefinedHelp text below the input. Hidden when error is present.
errorReactNodeundefinedError message below the input. true = red border only.
addonStartReactNodeundefinedInline-start addon (icons, text, dropdowns)
addonEndReactNodeundefinedInline-end addon (buttons, badges, spinners)
addonTopReactNodeundefinedBlock-start addon (toolbar, controls above the input)
addonBottomReactNodeundefinedBlock-end addon (character count, status bar below)
disabledbooleanfalseDisables interactions and applies disabled styles
requiredbooleanfalseMarks the input as required
classNamestringundefinedAdditional CSS classes for the root element

All standard HTML input attributes are also supported.

McInputButton Props

PropTypeDefaultDescription
variant"ghost" | "outline" | "filled" | "attached""ghost"Visual style. attached goes flush with the input border.
classNamestringundefinedAdditional CSS classes

All standard HTML button attributes are also supported.

On this page