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.
React Hook Form Performant forms with easy validation
Zod TypeScript-first schema validation
Tailwind CSS Utility-first styling system
Base Components
The base input component provides consistent styling and accessibility features.
File Location
apps/frontend/src/components/common/Input.tsx
Implementation
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 }
/>
Flexible button component with multiple variants and sizes.
File Location
apps/frontend/src/components/common/Button.tsx
Implementation
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
default
destructive
outline
ghost
Primary action button with brand colors Dangerous actions like delete < Button variant = "destructive" > Delete </ Button >
Secondary actions < Button variant = "outline" > Cancel </ Button >
Minimal button for subtle actions < Button variant = "ghost" > More </ 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 >
Creating a Schema
Define validation schemas with Zod:
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
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
Here’s the complete Login form implementation:
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
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 >
))
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