Skip to main content
Tambo360 uses a robust form system built on React Hook Form and Zod for type-safe validation. All form components are styled with Tailwind CSS and provide consistent error handling.

Form Stack

React Hook Form

Performant forms with easy validation

Zod

TypeScript-first schema validation

Tailwind CSS

Utility-first styling system

Base Components

Input Component

The base input component provides consistent styling and accessibility features.

File Location

apps/frontend/src/components/common/Input.tsx

Implementation

Input.tsx
import * as React from 'react'
import { cn } from '@/src/utils/utils'

function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
  return (
    <input
      type={type}
      data-slot="input"
      className={cn(
        'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground',
        'border-input h-12.5 w-full min-w-0 rounded-md border bg-[#F1F5F9] px-3 py-1 text-base shadow-xs',
        'transition-[color,box-shadow] outline-none',
        'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
        'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
        'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
        className
      )}
      {...props}
    />
  )
}

export { Input }

Features

  • Automatic Error States: Uses aria-invalid for error styling
  • Focus Ring: Visible focus indicator for accessibility
  • Disabled State: Visual feedback for disabled inputs
  • File Input Support: Special styling for file inputs

Usage

import { Input } from '@/src/components/common/Input'

<Input
  type="email"
  placeholder="Ingresa tu correo"
  aria-invalid={!!error}
  disabled={isLoading}
/>

Button Component

Flexible button component with multiple variants and sizes.

File Location

apps/frontend/src/components/common/Button.tsx

Implementation

Button.tsx
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import { cn } from '@/src/utils/utils'

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 outline-none",
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-white hover:bg-destructive/90',
        outline: 'border bg-background shadow-xs hover:bg-accent',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-9 px-4 py-2',
        xs: 'h-6 gap-1 rounded-md px-2 text-xs',
        sm: 'h-8 rounded-md gap-1.5 px-3',
        lg: 'h-10 rounded-md px-6',
        icon: 'size-9',
        'icon-sm': 'size-8',
        'icon-lg': 'size-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
)

function Button({
  className,
  variant = 'default',
  size = 'default',
  asChild = false,
  ...props
}: React.ComponentProps<'button'> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean
  }) {
  const Comp = asChild ? Slot.Root : 'button'

  return (
    <Comp
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

export { Button, buttonVariants }

Variants

Primary action button with brand colors
<Button>Submit</Button>

Sizes

<Button size="xs">Extra Small</Button>
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>

{/* Icon-only buttons */}
<Button size="icon"><Icon /></Button>

Label Component

Accessible form labels.

File Location

apps/frontend/src/components/common/label.tsx

Usage

import { Label } from '@/src/components/common/label'

<Label htmlFor="email" className={errors.email ? 'text-[#B91C1C]' : ''}>
  Correo electrónico
</Label>

Form Validation with Zod

Creating a Schema

Define validation schemas with Zod:
login.ts
import { z } from 'zod'

export const LoginSchema = z.object({
  correo: z
    .string()
    .min(1, 'El correo es requerido')
    .email('Correo inválido'),
  contraseña: z
    .string()
    .min(8, 'Mínimo 8 caracteres')
    .regex(/[A-Z]/, 'Debe tener una mayúscula')
    .regex(/[a-z]/, 'Debe tener una minúscula')
    .regex(/\d/, 'Debe tener un número')
    .regex(/[@$!%*?&]/, 'Debe tener un carácter especial'),
})

export type LoginData = z.infer<typeof LoginSchema>
Location: apps/frontend/src/types/login.ts

Password Reset Schema Example

auth.ts
import * as z from 'zod'

export const resetSchema = z.object({
  contraseña: z
    .string()
    .min(8, 'Mínimo 8 caracteres')
    .regex(/[A-Z]/, 'Debe tener una mayúscula')
    .regex(/[a-z]/, 'Debe tener una minúscula')
    .regex(/\d/, 'Debe tener un número')
    .regex(/[@$!%*?&]/, 'Debe tener un carácter especial'),
  confirm: z.string()
}).refine((data) => data.contraseña === data.confirm, {
  message: 'Las contraseñas no coinciden',
  path: ['confirm'],
})

export type ResetFormData = z.infer<typeof resetSchema>
Location: apps/frontend/src/types/auth.ts:3

Complete Form Example: Login

Here’s the complete Login form implementation:
Login.tsx
import { Card, CardContent } from '@/src/components/common/card'
import { Button } from '@/src/components/common/Button'
import { Label } from '@/src/components/common/label'
import { Input } from '@/src/components/common/Input'
import { Link, useNavigate } from 'react-router-dom'
import { useLogin } from '@/src/hooks/auth/useLogin'
import { useAuth } from '@/src/context/AuthContext'
import { EyeIcon, ArrowRight, EyeOff, AlertCircle } from 'lucide-react'
import { LoginSchema } from '@/src/types/login'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import React, { useState, useEffect } from 'react'
import { toast } from 'sonner'

const Login: React.FC = () => {
  const [showPassword, setShowPassword] = useState(false)
  const { mutateAsync, isPending, error: apiError } = useLogin()
  const navigate = useNavigate()
  const { login } = useAuth()

  const {
    register,
    handleSubmit,
    formState: { errors, submitCount },
  } = useForm({
    defaultValues: {
      correo: '',
      contraseña: '',
    },
    resolver: zodResolver(LoginSchema),
  })

  // Show error toast
  useEffect(() => {
    if (submitCount > 0 && Object.keys(errors).length > 0) {
      toast.custom(() => (
        <div className="flex items-center gap-3 bg-[#FCE8E5] border border-[#F87171] text-[#B91C1C] px-4 py-3 rounded-lg">
          <AlertCircle className="w-5 h-5" />
          <span>Revisa los campos resaltados e intenta nuevamente</span>
        </div>
      ))
    }
  }, [submitCount, errors])

  const onSubmit = handleSubmit(async (data) => {
    try {
      const response = await mutateAsync(data)
      login({ token: response.data.token, user: response.data.user })
      navigate('/dashboard')
    } catch (err) {
      console.error('Error al iniciar sesión:', err)
    }
  })

  return (
    <Card className="w-full max-w-125 border-none shadow-2xl">
      <CardContent className="space-y-8">
        <form onSubmit={onSubmit} className="space-y-6" noValidate>
          <div className="space-y-4">
            {/* Email Field */}
            <div className="space-y-2 text-left">
              <Label
                className={`font-bold ${
                  errors.correo ? 'text-[#B91C1C]' : 'text-[#0B1001]'
                }`}
              >
                Correo electrónico
              </Label>
              <Input
                type="email"
                placeholder="Ingresa tu correo electrónico"
                {...register('correo')}
                className={`h-14 ${
                  errors.correo
                    ? 'border-[#F87171] bg-[#FCE8E5]/30'
                    : 'border-[#D1CFCA] bg-[#F9F9F7]'
                }`}
                disabled={isPending}
              />
              {errors.correo && (
                <p className="text-xs font-medium text-[#B91C1C]">
                  {errors.correo.message}
                </p>
              )}
            </div>

            {/* Password Field */}
            <div className="space-y-2 text-left">
              <Label
                className={`font-bold ${
                  errors.contraseña ? 'text-[#B91C1C]' : 'text-[#0B1001]'
                }`}
              >
                Contraseña
              </Label>
              <div className="relative">
                <Input
                  type={showPassword ? 'text' : 'password'}
                  placeholder="••••••••••••"
                  {...register('contraseña')}
                  className={`h-14 ${
                    errors.contraseña
                      ? 'border-[#F87171] bg-[#FCE8E5]/30'
                      : 'border-[#D1CFCA] bg-[#F9F9F7]'
                  }`}
                  disabled={isPending}
                />
                <Button
                  type="button"
                  variant="ghost"
                  onClick={() => setShowPassword(!showPassword)}
                  className="absolute right-3 top-1/2 -translate-y-1/2"
                >
                  {showPassword ? <EyeOff /> : <EyeIcon />}
                </Button>
              </div>
              {errors.contraseña && (
                <p className="text-xs font-medium text-[#B91C1C]">
                  {errors.contraseña.message}
                </p>
              )}
            </div>
          </div>

          <Button
            type="submit"
            className="w-full h-14 rounded-lg text-lg font-medium"
            disabled={isPending}
          >
            {isPending ? 'Cargando...' : 'Iniciar sesión'}
            <ArrowRight className="size-5" />
          </Button>
        </form>

        <div className="text-center pt-4 border-t">
          <p className="text-sm text-[#626059]">
            ¿No tienes una cuenta?{' '}
            <Link to="/register" className="font-bold hover:underline">
              Regístrate
            </Link>
          </p>
        </div>
      </CardContent>
    </Card>
  )
}

export default Login
Location: apps/frontend/src/pages/Login.tsx

Form Patterns

Password Toggle

const [showPassword, setShowPassword] = useState(false)

<div className="relative">
  <Input type={showPassword ? 'text' : 'password'} {...register('password')} />
  <Button
    type="button"
    variant="ghost"
    onClick={() => setShowPassword(!showPassword)}
    className="absolute right-3 top-1/2 -translate-y-1/2"
  >
    {showPassword ? <EyeOff /> : <EyeIcon />}
  </Button>
</div>

Error Display

{errors.fieldName && (
  <p className="text-xs font-medium text-[#B91C1C]">
    {errors.fieldName.message}
  </p>
)}

Conditional Error Styling

<Input
  {...register('email')}
  className={errors.email ? 'border-[#F87171] bg-[#FCE8E5]/30' : ''}
  aria-invalid={!!errors.email}
/>

Toast Notifications

import { toast } from 'sonner'

// Success toast
toast.success('Formulario enviado correctamente')

// Error toast
toast.error('Ocurrió un error')

// Custom toast
toast.custom(() => (
  <div className="flex items-center gap-3 bg-[#FCE8E5] border border-[#F87171]">
    <AlertCircle className="w-5 h-5" />
    <span>Mensaje personalizado</span>
  </div>
))

Other Form Components

Textarea

Location: apps/frontend/src/components/common/textarea.tsx

Select

Location: apps/frontend/src/components/common/select.tsx

Checkbox

Location: apps/frontend/src/components/common/checkbox.tsx

Combobox

Autocomplete select component. Location: apps/frontend/src/components/common/combobox.tsx

Best Practices

Always use Zod schemas

Define validation schemas in the types/ directory for reusability

Use React Hook Form

Better performance than controlled components for large forms

Show clear error messages

Display validation errors inline below each field

Disable during submission

Always disable form inputs while isPending is true
Never store sensitive data like passwords in component state longer than necessary. Submit immediately after validation.

Auth Hooks

useLogin, useRegister, and other auth mutations

React Query

Learn about useMutation for form submissions

Build docs developers (and LLMs) love