I created a Next.js app following the ShadCN UI documentation: https://ui.shadcn.com/docs/installation/next. I'm using Tailwind v4 and now I want to add a form with Zod, so I followed this documentation: https://ui.shadcn.com/docs/components/form.
Here's my code (a direct copy from the documentation):
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
const FormSchema = z.object({
username: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
});
export default function InputForm() {
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
username: "",
},
});
function onSubmit(data: z.infer<typeof FormSchema>) {
console.log(data);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="w-2/3 space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}
However, the error messages aren't displaying correctly:
The label disappears. The error message isn't shown in red. Here’s my global.css:
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(0 0% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(0 0% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(0 0% 3.9%);
--primary: hsl(0 0% 9%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(0 0% 96.1%);
--secondary-foreground: hsl(0 0% 9%);
--muted: hsl(0 0% 96.1%);
--muted-foreground: hsl(0 0% 45.1%);
--accent: hsl(0 0% 96.1%);
--accent-foreground: hsl(0 0% 9%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 89.8%);
--input: hsl(0 0% 89.8%);
--ring: hsl(0 0% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.6rem;
}
.dark {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 14.9%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 14.9%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--animate-spinner-leaf-fade: spinner-leaf-fade 800ms linear infinite;
@keyframes spinner-leaf-fade {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
@apply scroll-smooth;
}
body {
@apply bg-background text-foreground;
}
button {
@apply cursor-pointer;
}
}
My package.json
{
"name": "share-pictures",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^4.1.3",
"@motionone/utils": "^10.18.0",
"@prisma/client": "^6.4.0",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@supabase/auth-helpers-nextjs": "^0.10.0",
"@supabase/supabase-js": "^2.48.1",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.4.7",
"lucide-react": "^0.475.0",
"next": "15.2.0-canary.65",
"next-themes": "^0.4.4",
"prisma": "^6.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-responsive-masonry": "^2.7.1",
"stripe": "^17.6.0",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-responsive-masonry": "^2.6.0",
"eslint": "^9",
"eslint-config-next": "15.2.0-canary.65",
"tailwindcss": "^4",
"typescript": "^5"
}
}
Am I missing something in my app or configuration?
Thanks!
The text-destructive
class would normally mean red as the default color for the error message. Without reproduction, I don't really understand why you have the text-destructive-foreground
class added to the error message. Maybe: shadcn-ui/new-york-v4/ui/form.tsx
#L150
.text-destructive {
--tw-text-opacity: 1;
color: hsl(var(--destructive) / var(--tw-text-opacity));
}
.text-destructive-foreground {
--tw-text-opacity: 1;
color: hsl(var(--destructive-foreground) / var(--tw-text-opacity));
}
However, you set the --destructive-foreground
CSS variable to hsl(0,0,98%)
, which is #fafafa
(a shade of gray), causing the text color to appear gray.
I don't understand the background color; maybe you selected it with the cursor, which could be misleading.
So you need to change the value of text-destructive-foreground
if the error message gets its text color from this class.
:root {
--destructive-foreground: hsl(0 62.8% 30.6%);
}
Due to the class name change in PR #6693 (form.tsx line 150) (what mentioned in issue #6832), the FormMessage text color is now set by text-destructive-foreground
instead of text-destructive
. This is likely a bug and will probably be fixed. In the meantime, you can temporarily set the FormMessage text color to red like this:
p[data-slot="form-message"].text-destructive-foreground {
--destructive-foreground: hsl(0 62.8% 30.6%);
}
This way, in all other cases, text-destructive-foreground
will remain the gray color you set (hsl(0, 0%, 98%)
), while in FormMessage, it will appear red.