quadratic/ui

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, so those technologies are recommended. If you're starting a new project, follow my 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

Make the following changes to your tailwind.config.ts file.

Add the darkMode property to the root of your config.

export default {
  ...
  darkMode: ["class"],
  ...
} satisfies Config;

Add the borderRadius, fontSize, lineHeight, and screens properties to theme.

export default {
  ...
  theme: {
    borderRadius: {
      0: "0",
      0.25: "0.0625rem",
      0.5: "0.125rem",
      1: "0.25rem",
      1.5: "0.375rem",
      2: "0.5rem",
      2.5: "0.625rem",
      3: "0.75rem",
      3.5: "0.875rem",
      4: "1rem",
      5: "1.25rem",
      6: "1.5rem",
      7: "1.75rem",
      8: "2rem",
      9: "2.25rem",
      10: "2.5rem",
      11: "2.75rem",
      12: "3rem",
      13: "3.25rem",
      14: "3.5rem",
      15: "3.75rem",
      16: "4rem",
      full: "9999px",
    },
    fontSize: {
      2.5: ["0.625rem", "0.875rem"],
      3: ["0.75rem", "1rem"],
      3.5: ["0.875rem", "1.25rem"],
      4: ["1rem", "1.5rem"],
      4.5: ["1.125rem", "1.75rem"],
      5: ["1.25rem", "1.75rem"],
      6: ["1.5rem", "2rem"],
      7: ["1.75rem", "2.25rem"],
      8: ["2rem", "2.25rem"],
      9: ["2.25rem", "2.5rem"],
      10: ["2.5rem", "3rem"],
      11: ["2.75rem", "3.25rem"],
      12: ["3rem", "3.5rem"],
      13: ["3.25rem", "3.75rem"],
      14: ["3.5rem", "4rem"],
      15: ["3.75rem", "4.25rem"],
      16: ["4rem", "4.5rem"],
    },
    lineHeight: {
      4: "1rem",
      5: "1.25rem",
      6: "1.5rem",
      7: "1.75rem",
      8: "2rem",
      9: "2.25rem",
      10: "2.5rem",
      11: "2.75rem",
      12: "3rem",
      13: "3.25rem",
      14: "3.5rem",
      15: "3.75rem",
      16: "4rem",
      17: "4.25rem",
      18: "4.5rem",
      19: "4.75rem",
      20: "5rem",
      21: "5.25rem",
      22: "5.5rem",
      23: "5.75rem",
      24: "6rem",
    },
    screens: {
      "3xs": "300px",
      "2xs": "360px",
      xs: "480px",
      sm: "640px",
      md: "768px",
      lg: "840px",
      xl: "1024px",
      "2xl": "1280px",
      "3xl": "1400px",
      "4xl": "1600px",
      "5xl": "1800px",
    },
  },
  ...
} satisfies Config;

Under theme.extend, add the colors and spacing properties.

import type { Config } from "tailwindcss";
import { fontFamily } from "tailwindcss/defaultTheme";
import tailwindcssAnimate from "tailwindcss-animate";

export default {
  ...,
  theme: {
    ...
    extend: {
      colors: {
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        primary: {
          DEFAULT: "hsl(var(--primary))",
          foreground: "hsl(var(--primary-foreground))",
        },
        secondary: {
          DEFAULT: "hsl(var(--secondary))",
          foreground: "hsl(var(--secondary-foreground))",
        },
        destructive: {
          DEFAULT: "hsl(var(--destructive))",
          foreground: "hsl(var(--destructive-foreground))",
          border: "hsl(var(--destructive-border))",
        },
        warning: {
          DEFAULT: "hsl(var(--warning))",
          foreground: "hsl(var(--warning-foreground))",
          border: "hsl(var(--warning-border))",
        },
        success: {
          DEFAULT: "hsl(var(--success))",
          foreground: "hsl(var(--success-foreground))",
          border: "hsl(var(--success-border))",
        },
        info: {
          DEFAULT: "hsl(var(--info))",
          foreground: "hsl(var(--info-foreground))",
          border: "hsl(var(--info-border))",
        },
        muted: {
          DEFAULT: "hsl(var(--muted))",
          foreground: "hsl(var(--muted-foreground))",
        },
        accent: {
          DEFAULT: "hsl(var(--accent))",
          foreground: "hsl(var(--accent-foreground))",
        },
        popover: {
          DEFAULT: "hsl(var(--popover))",
          foreground: "hsl(var(--popover-foreground))",
        },
        card: {
          DEFAULT: "hsl(var(--card))",
          foreground: "hsl(var(--card-foreground))",
        },
        heart: {
          DEFAULT: "hsl(var(--heart))",
          border: "hsl(var(--heart-border))",
        },
      },
    },
    spacing: {
      0: "0px",
      px: "1px",
      0.5: "0.125rem",
      1: "0.25rem",
      1.5: "0.375rem",
      2: "0.5rem",
      2.5: "0.625rem",
      3: "0.75rem",
      3.5: "0.875rem",
      4: "1rem",
      4.5: "1.125rem",
      5: "1.25rem",
      6: "1.5rem",
      7: "1.75rem",
      8: "2rem",
      9: "2.25rem",
      10: "2.5rem",
      11: "2.75rem",
      12: "3rem",
      13: "3.25rem",
      14: "3.5rem",
      15: "3.75rem",
      16: "4rem",
      17: "4.25rem",
      18: "4.5rem",
      19: "4.75rem",
      20: "5rem",
      21: "5.25rem",
      22: "5.5rem",
      23: "5.75rem",
      24: "6rem",
      25: "6.25rem",
      26: "6.5rem",
      27: "6.75rem",
      28: "7rem",
      29: "7.25rem",
      30: "7.5rem",
      32: "8rem",
      34: "8.25rem",
      36: "8.75rem",
      40: "10rem",
      42: "10.5rem",
      44: "11rem",
      46: "11.5rem",
      48: "12rem",
      50: "12.5rem",
      52: "13rem",
      54: "13.5rem",
      56: "14rem",
      58: "14.5rem",
      60: "15rem",
      62: "15.5rem",
      64: "16rem",
      66: "16.5rem",
      68: "17rem",
      70: "17.5rem",
      72: "18rem",
      74: "18.5rem",
      76: "19rem",
      78: "19.5rem",
      80: "20rem",
      96: "24rem",
      112: "28rem",
      128: "32rem",
      144: "36rem",
      168: "42rem",
      192: "48rem",
      224: "56rem",
      256: "64rem",
      288: "72rem",
      320: "80rem",
    },
  },
  ...
} satisfies Config;

Add the tailwindcss-animate plugin to root of the config.

export default {
  ...
  plugins: [require("tailwindcss-animate")],
} satisfies Config;

In the end, your tailwind.config.ts file should look like this:

import type { Config } from "tailwindcss";

export default {
  darkMode: ["class"],
  content: ["./src/**/*.tsx"],
  theme: {
    borderRadius: {
      none: "0",
      px: "0.0625rem",
      "0.5": "0.125rem",
      1: "0.25rem",
      "1.5": "0.375rem",
      2: "0.5rem",
      "2.5": "0.625rem",
      3: "0.75rem",
      "3.5": "0.875rem",
      4: "1rem",
      5: "1.25rem",
      6: "1.5rem",
      7: "1.75rem",
      8: "2rem",
      9: "2.25rem",
      10: "2.5rem",
      11: "2.75rem",
      12: "3rem",
      13: "3.25rem",
      14: "3.5rem",
      15: "3.75rem",
      16: "4rem",
      full: "9999px",
    },
    fontSize: {
      2.5: ["0.625rem", "0.75rem"],
      3: ["0.75rem", "1rem"],
      3.5: ["0.875rem", "1.25rem"],
      4: ["1rem", "1.5rem"],
      4.5: ["1.125rem", "1.75rem"],
      5: ["1.25rem", "1.75rem"],
      6: ["1.5rem", "2rem"],
      7: ["1.75rem", "2.25rem"],
      8: ["2rem", "2.5rem"],
      9: ["2.25rem", "2.75rem"],
      10: ["2.5rem", "3rem"],
      11: ["2.75rem", "3.5rem"],
      12: ["3rem", "4rem"],
      13: ["3.25rem", "4.5rem"],
      14: ["3.5rem", "5rem"],
      15: ["3.75rem", "5.5rem"],
      16: ["4rem", "6rem"],
    },
    lineHeight: {
      5: "1.25rem",
      5.5: "1.375rem",
      6: "1.5rem",
      7: "1.75rem",
      8: "2rem",
      9: "2.25rem",
      10: "2.5rem",
      11: "2.75rem",
      12: "3rem",
      13: "3.25rem",
      14: "3.5rem",
      15: "3.75rem",
      16: "4rem",
    },
    screens: {
      "3xs": "300px",
      "2xs": "360px",
      xs: "480px",
      sm: "640px",
      md: "768px",
      lg: "840px",
      xl: "1024px",
      "2xl": "1280px",
      "3xl": "1400px",
      "4xl": "1600px",
      "5xl": "1800px",
    },
    extend: {
      colors: {
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        primary: {
          DEFAULT: "hsl(var(--primary))",
          foreground: "hsl(var(--primary-foreground))",
        },
        secondary: {
          DEFAULT: "hsl(var(--secondary))",
          foreground: "hsl(var(--secondary-foreground))",
        },
        destructive: {
          DEFAULT: "hsl(var(--destructive))",
          foreground: "hsl(var(--destructive-foreground))",
          border: "hsl(var(--destructive-border))",
        },
        warning: {
          DEFAULT: "hsl(var(--warning))",
          foreground: "hsl(var(--warning-foreground))",
          border: "hsl(var(--warning-border))",
        },
        success: {
          DEFAULT: "hsl(var(--success))",
          foreground: "hsl(var(--success-foreground))",
          border: "hsl(var(--success-border))",
        },
        info: {
          DEFAULT: "hsl(var(--info))",
          foreground: "hsl(var(--info-foreground))",
          border: "hsl(var(--info-border))",
        },
        muted: {
          DEFAULT: "hsl(var(--muted))",
          foreground: "hsl(var(--muted-foreground))",
        },
        accent: {
          DEFAULT: "hsl(var(--accent))",
          foreground: "hsl(var(--accent-foreground))",
        },
        popover: {
          DEFAULT: "hsl(var(--popover))",
          foreground: "hsl(var(--popover-foreground))",
        },
        card: {
          DEFAULT: "hsl(var(--card))",
          foreground: "hsl(var(--card-foreground))",
        },
        highlight: {
          DEFAULT: "hsl(var(--highlight))",
          foreground: "hsl(var(--highlight-foreground))",
        },
      },
      spacing: {
        0: "0px",
        px: "1px",
        0.5: "0.125rem",
        1: "0.25rem",
        1.5: "0.375rem",
        2: "0.5rem",
        2.5: "0.625rem",
        3: "0.75rem",
        3.5: "0.875rem",
        4: "1rem",
        4.5: "1.125rem",
        5: "1.25rem",
        6: "1.5rem",
        7: "1.75rem",
        8: "2rem",
        9: "2.25rem",
        10: "2.5rem",
        11: "2.75rem",
        12: "3rem",
        13: "3.25rem",
        14: "3.5rem",
        15: "3.75rem",
        16: "4rem",
        17: "4.25rem",
        18: "4.5rem",
        19: "4.75rem",
        20: "5rem",
        21: "5.25rem",
        22: "5.5rem",
        23: "5.75rem",
        24: "6rem",
        25: "6.25rem",
        26: "6.5rem",
        27: "6.75rem",
        28: "7rem",
        29: "7.25rem",
        30: "7.5rem",
        32: "8rem",
        34: "8.25rem",
        36: "8.75rem",
        40: "10rem",
        42: "10.5rem",
        44: "11rem",
        46: "11.5rem",
        48: "12rem",
        50: "12.5rem",
        52: "13rem",
        54: "13.5rem",
        56: "14rem",
        58: "14.5rem",
        60: "15rem",
        62: "15.5rem",
        64: "16rem",
        66: "16.5rem",
        68: "17rem",
        70: "17.5rem",
        72: "18rem",
        74: "18.5rem",
        76: "19rem",
        78: "19.5rem",
        80: "20rem",
        96: "24rem",
        112: "28rem",
        128: "32rem",
        144: "36rem",
        168: "42rem",
        192: "48rem",
        224: "56rem",
        256: "64rem",
        288: "72rem",
        320: "80rem",
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
} satisfies Config;

Add the following colors and styles to your styles/globals.css file.

@tailwind base;
@tailwind components;
@tailwind utilities;

@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;
  }

  html {
    @apply scroll-smooth;
  }

  body {
    @apply bg-background font-sans text-foreground antialiased;
    font-feature-settings:
      "rlig" 1,
      "calt" 1;
  }
}

Create a utils/tailwind.ts file and add some helper functions for Tailwind CSS.

import { clsx, type ClassValue } from "clsx";
import { extendTailwindMerge } from "tailwind-merge";
import { createTV } from "tailwind-variants";

import config from "../../tailwind.config";

const classGroups = {
  "font-size": Object.keys(config.theme.fontSize).map((key) => `text-${key}`),
  "text-color": Object.keys(config.theme.extend.colors).map(
    (key) => `text-${key}`,
  ),
  rounded: Object.keys(config.theme.borderRadius).map(
    (key) => `rounded-${key}`,
  ),
  "border-color": Object.keys(config.theme.extend.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: [
    "fixed left-1/2 top-1/2 z-50 grid w-[calc(100%-2.5rem)] max-w-128 -translate-x-1/2 -translate-y-1/2 rounded-4 border bg-background p-6",
    "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
    "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
    "[&>button]:absolute [&>button]:right-2.5 [&>button]:top-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 leading-6 text-muted-foreground",
});

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: [
    "flex cursor-default select-none items-center rounded-1 py-1.5 pr-2 text-3.5 outline-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: [
    "z-50 min-w-32 overflow-hidden rounded-2 border bg-popover p-1 text-popover-foreground",
    "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: [
    "z-50 min-w-32 overflow-hidden rounded-2 border bg-popover p-1 text-popover-foreground",
    "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: [
    "relative flex cursor-pointer select-none items-center rounded-1 py-1.5 pr-2 text-3.5 outline-none transition-colors",
    "focus:bg-accent focus:text-accent-foreground",
    "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
    "[&>svg]:pointer-events-none [&>svg]:absolute [&>svg]:left-2 [&>svg]:top-1/2 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:-translate-y-1/2",
  ],
  variants: {
    inset: {
      true: "pl-8",
      false: "pl-2",
    },
  },
});

const menuLabelVariants = tv({
  base: "py-1 pr-2 text-3.5 font-semibold",
  variants: {
    inset: {
      true: "pl-8",
      false: "pl-2",
    },
  },
});

const menuSeparatorVariants = tv({
  base: "-mx-1 my-1 h-0.25 w-full shrink-0 bg-border",
});

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(
        "flex size-5 items-center justify-center overflow-hidden rounded-1 border bg-background font-mono text-3.5 text-muted-foreground",
        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 NextLink from "next/link";

interface _LinkProps extends React.ComponentProps<"a"> {
  href: string;
  className?: string;
}

export default function _Link({ href, className, ...props }: _LinkProps) {
  const Comp = href.startsWith("/") ? NextLink : "a";

  return (
    <Comp
      href={href}
      className={className}
      {...(Comp= "a" && { target: "_blank", rel: "noopener noreferrer" })}
      {...props}
    />
  );
}

That's it! Enjoy!