Quickstart
Add quadratic/ui to your Next.js 15 + React 19 app.
Create a Next.js 15 + React 19 app any way you like. Our documentation uses pnpm, TypeScript, and Tailwind CSS v4, so those technologies are recommended. If you're starting a new project, follow the Create T3 App guide, which provides the best starting point for building Next.js apps.
In your project root, run the following script to install some dependencies to your project.
pnpm add tailwindcss-animate tailwind-variants clsx tailwind-merge lucide-react
Replace the contents of your globals.css
file with the following.
@import "tailwindcss";
@plugin "tailwindcss-animate";
@variant dark (&:where(.dark, .dark *));
@theme {
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
--animate-scale-in: scale-in 0.2s ease;
--animate-scale-out: scale-out 0.2s ease;
--animate-fade-in: fade-in 0.2s ease;
--animate-fade-out: fade-out 0.2s ease;
--breakpoint-3xs: 18.75rem;
--breakpoint-2xs: 22.5rem;
--breakpoint-xs: 30rem;
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 52.5rem;
--breakpoint-xl: 64rem;
--breakpoint-2xl: 80rem;
--breakpoint-3xl: 87.5rem;
--breakpoint-4xl: 100rem;
--breakpoint-5xl: 112.5rem;
--font-sans: ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco,
Consolas, "Liberation Mono", "Courier New", monospace;
--radius-0: 0;
--radius-0-25: 0.0625rem;
--radius-0-5: 0.125rem;
--radius-1: 0.25rem;
--radius-1-5: 0.375rem;
--radius-2: 0.5rem;
--radius-2-5: 0.625rem;
--radius-3: 0.75rem;
--radius-3-5: 0.875rem;
--radius-4: 1rem;
--radius-5: 1.25rem;
--radius-6: 1.5rem;
--radius-7: 1.75rem;
--radius-8: 2rem;
--radius-9: 2.25rem;
--radius-10: 2.5rem;
--radius-11: 2.75rem;
--radius-12: 3rem;
--radius-13: 3.25rem;
--radius-14: 3.5rem;
--radius-15: 3.75rem;
--radius-16: 4rem;
--radius-full: 9999px;
--text-2.5: 0.625rem;
--text-2.5--line-height: 0.875rem;
--text-3: 0.75rem;
--text-3--line-height: 1rem;
--text-3.5: 0.875rem;
--text-3.5--line-height: 1.25rem;
--text-4: 1rem;
--text-4--line-height: 1.5rem;
--text-4.5: 1.125rem;
--text-4.5--line-height: 1.75rem;
--text-5: 1.25rem;
--text-5--line-height: 1.75rem;
--text-6: 1.5rem;
--text-6--line-height: 2rem;
--text-7: 1.75rem;
--text-7--line-height: 2.25rem;
--text-8: 2rem;
--text-8--line-height: 2.25rem;
--text-9: 2.25rem;
--text-9--line-height: 2.5rem;
--text-10: 2.5rem;
--text-10--line-height: 3rem;
--text-11: 2.75rem;
--text-11--line-height: 3.25rem;
--text-12: 3rem;
--text-12--line-height: 3.5rem;
--text-13: 3.25rem;
--text-13--line-height: 3.75rem;
--text-14: 3.5rem;
--text-14--line-height: 4rem;
--text-15: 3.75rem;
--text-15--line-height: 4.25rem;
--text-16: 4rem;
@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;
}
}
@keyframes scale-in {
0% {
opacity: 0;
transform: rotateX(-10deg) scale(0.9);
}
100% {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
}
@keyframes scale-out {
0% {
opacity: 1;
transform: rotateX(0deg) scale(1);
}
100% {
opacity: 0;
transform: rotateX(-10deg) scale(0.95);
}
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
}
@theme inline {
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-destructive-border: hsl(var(--destructive-border));
--color-warning: hsl(var(--warning));
--color-warning-foreground: hsl(var(--warning-foreground));
--color-warning-border: hsl(var(--warning-border));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-success-border: hsl(var(--success-border));
--color-info: hsl(var(--info));
--color-info-foreground: hsl(var(--info-foreground));
--color-info-border: hsl(var(--info-border));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-heart: hsl(var(--heart));
--color-heart-border: hsl(var(--heart-border));
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 4%;
--primary: 240 6% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 5% 100%;
--secondary-foreground: 240 6% 10%;
--muted: 240 5% 96%;
--muted-foreground: 240 4% 46%;
--accent: 240 5% 96%;
--accent-foreground: 240 6% 10%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 4%;
--card: 0 0% 100%;
--card-foreground: 240 10% 4%;
--destructive: 0 100% 97%;
--destructive-foreground: 0 100% 45%;
--destructive-border: 358 100% 94%;
--warning: 48 100% 97%;
--warning-foreground: 31 92% 45%;
--warning-border: 49 91% 91%;
--success: 145 81% 96%;
--success-foreground: 140 100% 27%;
--success-border: 146 91% 91%;
--info: 208 100% 97%;
--info-foreground: 210 92% 45%;
--info-border: 221 91% 91%;
--border: 240 6% 90%;
--input: 240 6% 90%;
--ring: 240 5% 65%;
--chart-1: 173 58% 39%;
--chart-2: 12 76% 61%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 240 10% 4%;
--foreground: 180 7% 97%;
--primary: 180 7% 97%;
--primary-foreground: 240 6% 10%;
--secondary: 240 4% 16%;
--secondary-foreground: 180 7% 97%;
--muted: 240 4% 16%;
--muted-foreground: 240 5% 65%;
--accent: 240 6% 10%;
--accent-foreground: 180 7% 97%;
--popover: 240 10% 4%;
--popover-foreground: 180 7% 97%;
--card: 240 10% 4%;
--card-foreground: 180 7% 97%;
--destructive: 358 76% 10%;
--destructive-foreground: 358 100% 81%;
--destructive-border: 357 90% 16%;
--warning: 64 100% 6%;
--warning-foreground: 46 87% 65%;
--warning-border: 60 100% 12%;
--success: 149 100% 6%;
--success-foreground: 150 87% 65%;
--success-border: 148 100% 12%;
--info: 215 100% 6%;
--info-foreground: 216 87% 65%;
--info-border: 223 100% 12%;
--border: 240 4% 16%;
--input: 240 4% 16%;
--ring: 240 5% 84%;
--chart-1: 220 70% 50%;
--chart-2: 340 75% 55%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 160 60% 45%;
}
* {
@apply border-border;
}
body {
font-feature-settings:
"rlig" 1,
"calt" 1;
}
}
Create a constants/tailwind.ts
file and add constants for Tailwind CSS classes.
export const FONT_SIZES = [
"2.5",
"3",
"3.5",
"4",
"4.5",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
"16",
];
export const BORDER_RADII = [
"0",
"0.25",
"0.5",
"1",
"1.5",
"2",
"2.5",
"3",
"3.5",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
"16",
"full",
];
export const COLORS = [
"border",
"input",
"ring",
"background",
"foreground",
"primary",
"primary-foreground",
"secondary",
"secondary-foreground",
"destructive",
"destructive-foreground",
"destructive-border",
"warning",
"warning-foreground",
"warning-border",
"success",
"success-foreground",
"success-border",
"info",
"info-foreground",
"info-border",
"muted",
"muted-foreground",
"accent",
"accent-foreground",
"popover",
"popover-foreground",
"card",
"card-foreground",
"heart",
"heart-border",
];
Create a utils/tailwind.ts
file and add helper functions for Tailwind CSS.
import { clsx, type ClassValue } from "clsx";
import { extendTailwindMerge } from "tailwind-merge";
import { createTV } from "tailwind-variants";
import { BORDER_RADII, COLORS, FONT_SIZES } from "~/constants/tailwind";
const classGroups = {
"font-size": FONT_SIZES.map((key) => `text-${key}`),
"text-color": COLORS.map((key) => `text-${key}`),
rounded: BORDER_RADII.map((key) => `rounded-${key}`),
"border-color": COLORS.map((key) => `border-${key}`),
};
const twMerge = extendTailwindMerge({ extend: { classGroups } });
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const tv = createTV({ twMerge: true, twMergeConfig: { classGroups } });
Add a components/ui/_Dialog.tsx
file. These are reusable styles for dialog components.
import { tv } from "~/utils/tailwind";
const dialogOverlayVariants = tv({
base: [
"fixed inset-0 z-50 bg-black/80",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
],
});
const dialogContentVariants = tv({
base: [
"rounded-4 bg-background fixed top-1/2 left-1/2 z-50 grid w-[calc(100%-2.5rem)] max-w-128 -translate-x-1/2 -translate-y-1/2 border p-6",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"[&>button]:absolute [&>button]:top-2.5 [&>button]:right-2.5",
],
});
const dialogHeaderVariants = tv({
base: ["flex flex-col gap-y-1.5 pb-5", "xs:pb-6"],
});
const dialogFooterVariants = tv({
base: [
"flex flex-col-reverse gap-y-3 pt-7",
"xs:flex-row xs:justify-end xs:gap-x-4 xs:pt-8",
],
});
const dialogTitleVariants = tv({
base: "text-5 font-semibold",
});
const dialogDescriptionVariants = tv({
base: "text-3.5 text-muted-foreground leading-6",
});
export {
dialogOverlayVariants,
dialogContentVariants,
dialogHeaderVariants,
dialogFooterVariants,
dialogTitleVariants,
dialogDescriptionVariants,
};
Add a components/ui/_Menu.tsx
file. These are reusable styles for menu components.
import { cn, tv } from "~/utils/tailwind";
const menuSubTriggerVariants = tv({
base: [
"rounded-1 text-3.5 flex cursor-default items-center py-1.5 pr-2 outline-hidden select-none",
"focus:bg-accent focus:text-accent-foreground",
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
"[&>svg]:pointer-events-none [&>svg]:ml-auto [&>svg]:size-4.5 [&>svg]:shrink-0",
],
variants: {
inset: {
true: "pl-8",
false: "pl-2",
},
},
});
const menuSubContentVariants = tv({
base: [
"rounded-2 bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden border p-1",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
],
});
const menuContentVariants = tv({
base: [
"rounded-2 bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden border p-1",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
],
variants: {
hasClosedAnimateOut: {
true: "data-[state=closed]:animate-out",
false: "",
},
},
});
const menuItemVariants = tv({
base: [
"rounded-1 text-3.5 relative flex cursor-pointer items-center py-1.5 pr-2 outline-hidden transition-colors select-none",
"focus:bg-accent focus:text-accent-foreground",
"data-disabled:pointer-events-none data-disabled:opacity-50",
"[&>svg]:pointer-events-none [&>svg]:absolute [&>svg]:top-1/2 [&>svg]:left-2 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:-translate-y-1/2",
],
variants: {
inset: {
true: "pl-8",
false: "pl-2",
},
},
});
const menuLabelVariants = tv({
base: "text-3.5 py-1 pr-2 font-semibold",
variants: {
inset: {
true: "pl-8",
false: "pl-2",
},
},
});
const menuSeparatorVariants = tv({
base: "bg-border -mx-1 my-1 h-0.25 w-full shrink-0",
});
function ShortcutGroup({
children,
className,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("ml-auto flex gap-x-1", className)} {...props}>
{children}
</div>
);
}
function Shortcut({
children,
className,
...props
}: React.ComponentProps<"span">) {
return (
<kbd
className={cn(
"rounded-1 bg-background text-3.5 text-muted-foreground flex size-5 items-center justify-center overflow-hidden border font-mono",
className,
)}
{...props}
>
{children}
</kbd>
);
}
export {
menuSubTriggerVariants,
menuSubContentVariants,
menuContentVariants,
menuItemVariants,
menuLabelVariants,
menuSeparatorVariants,
ShortcutGroup,
Shortcut,
};
Add a components/ui/_Link.tsx
file. This is a link component that determines whether to use Next.js's <Link>
or <a>
tag based on the href
prop.
import Link from "next/link";
interface _LinkProps extends React.ComponentProps<typeof Link> {
href: string;
}
export default function _Link({ href, className, ...props }: _LinkProps) {
const Comp = href.startsWith("/") ? Link : "a";
return (
<Comp
href={href}
className={className}
{...(!href.startsWith("/") && {
target: "_blank",
rel: "noopener noreferrer",
})}
{...props}
/>
);
}
Add a components/ui/_VisuallyHidden.tsx
file. This is to hide content visually but still allow it to be read by screen readers.
import * as VisuallyHiddenPrimitive from "@radix-ui/react-visually-hidden";
const VisuallyHidden = VisuallyHiddenPrimitive.Root;
export { VisuallyHidden };