Logo

UI Components Kit

Ready to use UI components for your Astro projects. Just click "Copy Code" and paste it directly into your project.

Buttons

Button.tsx

import { cn } from '../../lib/utils';
import type { ButtonHTMLAttributes } from 'react';

/**
 * Button component
 * ─────────────────
 * Polymorphic button with 5 variants and 3 sizes.
 * Uses CSS custom properties so it adapts to any theme automatically.
 *
 * @example
 * // Primary CTA
 * <Button variant="primary" size="lg">Get Started</Button>
 *
 * @example
 * // Ghost navigation link
 * <Button variant="ghost" size="sm" as="a" href="/about">About</Button>
 */

type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg' | 'xl';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  /** Render as a different element (e.g. 'a' for links) */
  as?: 'button' | 'a';
  href?: string;
  /** Show a loading spinner */
  loading?: boolean;
  /** Full width */
  fullWidth?: boolean;
  children: React.ReactNode;
}

const variantClasses: Record<ButtonVariant, string> = {
  primary:
    'bg-[var(--color-primary)] text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-dark)] shadow-md hover:shadow-lg',
  secondary:
    'bg-[var(--color-bg-muted)] text-[var(--color-text)] hover:bg-[var(--color-border-strong)]',
  outline:
    'border border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-text-inverse)]',
  ghost:
    'text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-bg-subtle)]',
  danger:
    'bg-red-600 text-white hover:bg-red-700 shadow-md hover:shadow-lg',
};

const sizeClasses: Record<ButtonSize, string> = {
  sm:  'px-3 py-1.5 text-sm rounded-[var(--radius-md)]',
  md:  'px-5 py-2.5 text-sm rounded-[var(--radius-lg)]',
  lg:  'px-7 py-3 text-base rounded-[var(--radius-lg)]',
  xl:  'px-9 py-4 text-lg rounded-[var(--radius-xl)]',
};

export function Button({
  variant = 'primary',
  size = 'md',
  as: Tag = 'button',
  href,
  loading = false,
  fullWidth = false,
  className,
  children,
  disabled,
  ...rest
}: ButtonProps) {
  const classes = cn(
    'inline-flex items-center justify-center gap-2 font-semibold',
    'transition-all duration-[var(--transition-base)]',
    'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-primary)]',
    'cursor-pointer select-none',
    variantClasses[variant],
    sizeClasses[size],
    fullWidth && 'w-full',
    (disabled || loading) && 'opacity-60 pointer-events-none',
    className,
  );

  if (Tag === 'a') {
    return (
      <a href={href} className={classes} {...(rest as any)}>
        {loading && <Spinner />}
        {children}
      </a>
    );
  }

  return (
    <button className={classes} disabled={disabled || loading} {...rest}>
      {loading && <Spinner />}
      {children}
    </button>
  );
}

function Spinner() {
  return (
    <svg
      className="animate-spin h-4 w-4"
      fill="none"
      viewBox="0 0 24 24"
      aria-hidden="true"
    >
      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
      <path
        className="opacity-75"
        fill="currentColor"
        d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
      />
    </svg>
  );
}

Inputs

Input.tsx

This username is available!

import { cn } from '../../lib/utils';
import type { InputHTMLAttributes } from 'react';

/**
 * Input component
 * ────────────────
 * Accessible form input with label, helper text, and validation states.
 *
 * @example
 * <Input
 *   label="Email address"
 *   type="email"
 *   placeholder="you@example.com"
 *   helperText="We'll never share your email."
 * />
 *
 * @example
 * <Input
 *   label="Name"
 *   error="This field is required."
 * />
 */

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  helperText?: string;
  error?: string;
  /** Show success state */
  success?: boolean;
  /** Wrapper class */
  wrapperClass?: string;
}

export function Input({
  label,
  helperText,
  error,
  success,
  id,
  wrapperClass,
  className,
  ...rest
}: InputProps) {
  const inputId = id ?? `input-${Math.random().toString(36).slice(2, 7)}`;

  return (
    <div className={cn('flex flex-col gap-1.5', wrapperClass)}>
      {label && (
        <label
          htmlFor={inputId}
          className="text-sm font-medium text-[var(--color-text)]"
        >
          {label}
        </label>
      )}

      <input
        id={inputId}
        className={cn(
          'w-full rounded-[var(--radius-lg)] border px-4 py-2.5 text-sm',
          'bg-[var(--color-bg)] text-[var(--color-text)]',
          'placeholder:text-[var(--color-text-subtle)]',
          'transition-colors duration-[var(--transition-fast)]',
          'outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:ring-offset-1',
          error
            ? 'border-red-500 focus:ring-red-500'
            : success
            ? 'border-emerald-500 focus:ring-emerald-500'
            : 'border-[var(--color-border)] hover:border-[var(--color-border-strong)]',
          className,
        )}
        aria-describedby={helperText ? `${inputId}-helper` : undefined}
        aria-invalid={error ? 'true' : undefined}
        {...rest}
      />

      {error && (
        <p id={`${inputId}-helper`} className="text-xs text-red-600" role="alert">
          {error}
        </p>
      )}
      {!error && helperText && (
        <p id={`${inputId}-helper`} className="text-xs text-[var(--color-text-muted)]">
          {helperText}
        </p>
      )}
    </div>
  );
}

Badges

Badge.astro

DefaultPrimarySuccessWarningDangerOutline
---
/**
 * Badge.astro
 * ────────────
 * Inline label for status, categories, and tags.
 *
 * Props:
 * @prop variant - 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'outline'
 * @prop size    - 'sm' | 'md'
 *
 * @example
 * <Badge variant="primary">New</Badge>
 * <Badge variant="success">Live</Badge>
 * <Badge variant="outline">Beta</Badge>
 */

interface Props {
  variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'outline';
  size?: 'sm' | 'md';
  class?: string;
}

const { variant = 'default', size = 'md', class: className = '' } = Astro.props;

const variantStyles = {
  default: 'bg-[var(--color-bg-muted)] text-[var(--color-text-muted)]',
  primary: 'bg-[var(--color-primary-light)] text-[var(--color-primary-dark)]',
  success: 'bg-emerald-100 text-emerald-700',
  warning: 'bg-amber-100 text-amber-700',
  danger:  'bg-red-100 text-red-700',
  outline: 'border border-[var(--color-primary)] text-[var(--color-primary)] bg-transparent',
};

const sizeStyles = {
  sm: 'px-2 py-0.5 text-xs',
  md: 'px-2.5 py-1 text-xs',
};
---

<span
  class={`inline-flex items-center gap-1 font-semibold rounded-full ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
>
  <slot />
</span>

Cards

Card.astro

Cards are versatile containers that can hold a variety of content like images, text, and actions.

---
/**
 * Card.astro
 * ───────────
 * Surface container with border, shadow, and optional hover lift.
 *
 * Props:
 * @prop padding  - 'none' | 'sm' | 'md' | 'lg'
 * @prop hover    - Whether to show hover lift effect
 * @prop bordered - Whether to show border
 * @prop class    - Additional CSS classes
 *
 * @example
 * <Card padding="lg" hover>
 *   <h3>Feature Title</h3>
 *   <p>Description text here.</p>
 * </Card>
 */

interface Props {
  padding?: 'none' | 'sm' | 'md' | 'lg';
  hover?: boolean;
  bordered?: boolean;
  class?: string;
}

const {
  padding = 'md',
  hover = false,
  bordered = true,
  class: className = '',
} = Astro.props;

const paddingStyles = {
  none: '',
  sm:   'p-4',
  md:   'p-6',
  lg:   'p-8',
};
---

<div
  class={[
    'rounded-[var(--radius-xl)] bg-[var(--color-bg-subtle)]',
    bordered ? 'border border-[var(--color-border)]' : '',
    hover ? 'transition-all duration-[var(--transition-slow)] hover:-translate-y-1 hover:shadow-[var(--shadow-xl)]' : 'shadow-[var(--shadow-md)]',
    paddingStyles[padding],
    className,
  ].join(' ')}
>
  <slot />
</div>

Alerts

Alert.tsx

import { cn } from '../../lib/utils';

/**
 * Alert component
 * ───────────────
 * Displays an important message in a way that attracts the user's attention.
 *
 * @example
 * <Alert variant="warning" title="Warning">
 *   Your subscription is expiring soon.
 * </Alert>
 */

type AlertVariant = 'info' | 'success' | 'warning' | 'error';

interface AlertProps {
  variant?: AlertVariant;
  title?: string;
  children: React.ReactNode;
  className?: string;
}

const variantClasses: Record<AlertVariant, string> = {
  info: 'bg-blue-50 text-blue-800 border-blue-200',
  success: 'bg-emerald-50 text-emerald-800 border-emerald-200',
  warning: 'bg-amber-50 text-amber-800 border-amber-200',
  error: 'bg-red-50 text-red-800 border-red-200',
};

const iconClasses: Record<AlertVariant, string> = {
  info: 'text-blue-500',
  success: 'text-emerald-500',
  warning: 'text-amber-500',
  error: 'text-red-500',
};

export function Alert({ variant = 'info', title, children, className }: AlertProps) {
  return (
    <div
      role="alert"
      className={cn(
        'rounded-[var(--radius-lg)] border p-4 flex gap-3',
        variantClasses[variant],
        className
      )}
    >
      <div className={cn('mt-0.5 flex-shrink-0', iconClasses[variant])}>
        {/* Simple icon based on variant */}
        {variant === 'info' && (
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
        )}
        {variant === 'success' && (
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></svg>
        )}
        {variant === 'warning' && (
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
        )}
        {variant === 'error' && (
          <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
        )}
      </div>
      <div>
        {title && <h5 className="font-semibold mb-1">{title}</h5>}
        <div className="text-sm opacity-90">{children}</div>
      </div>
    </div>
  );
}

Avatars

Avatar.astro

SM
MD
LG
User
---
/**
 * Avatar.astro
 * ────────────
 * Displays a user's profile picture or initials.
 *
 * @example
 * <Avatar src="/images/user.jpg" alt="John Doe" size="md" />
 * <Avatar fallback="JD" size="lg" />
 */

interface Props {
  src?: string;
  alt?: string;
  fallback?: string;
  size?: 'sm' | 'md' | 'lg' | 'xl';
  class?: string;
}

const { src, alt = '', fallback = '?', size = 'md', class: className = '' } = Astro.props;

const sizeStyles = {
  sm: 'w-8 h-8 text-xs',
  md: 'w-10 h-10 text-sm',
  lg: 'w-12 h-12 text-base',
  xl: 'w-16 h-16 text-lg',
};
---

<div
  class={`relative inline-flex items-center justify-center overflow-hidden bg-zinc-200 text-zinc-600 rounded-full shrink-0 ${sizeStyles[size]} ${className}`}
>
  {src ? (
    <img src={src} alt={alt} class="w-full h-full object-cover" loading="lazy" />
  ) : (
    <span class="font-medium uppercase">{fallback}</span>
  )}
</div>