Chakra UI in React: Layout Primitives, Theming, Dark Mode and Accessible Components

Key takeaways

How Chakra UI v3's style props, layout primitives, compound components and token-based theming fit together, how dark mode works now that ColorModeScript is gone, and what breaks when code written for Chakra v2 meets v3.

What this post covers

Chakra UI is a React component library built around style props: instead of writing CSS classes, you pass design-system values directly to components (<Box p={4} bg="bg.panel" borderRadius="md">). It also provides accessible interactive components such as dialogs, menus and tabs.

This post targets Chakra UI v3, which changed a lot compared with v2: the theme API, form components, color mode handling and many prop names are different. Many tutorials, answers and AI-generated snippets online still use v2 APIs, so the post calls out the v2 equivalents where they differ. The last section summarizes what breaks in a migration.

When Chakra is a good fit

Chakra’s style props make it fast to build and adjust layouts without switching between files, and its token system keeps spacing, colors and radii consistent because you refer to 4 or gray.200 instead of arbitrary values. Its interactive components in v3 are built on Ark UI and Zag.js state machines, which handle focus management, keyboard navigation and ARIA attributes for things like dialogs and menus, the parts of accessibility that are genuinely hard to get right by hand.

That does not make an app accessible automatically. You still need labels for inputs, text alternatives for icon buttons, sufficient color contrast in your palette, and a sensible heading structure. And like other CSS-in-JS libraries, Chakra computes styles at runtime, which adds some JavaScript and work in the browser compared with a compile-time approach such as Tailwind CSS. If you want to own and edit every component’s source, a copy-in library such as shadcn/ui is the other end of the spectrum.


Installation and the provider

npm install @chakra-ui/react @emotion/react

Version 3 no longer depends on framer-motion or @emotion/styled; animations use CSS. Wrap your app in ChakraProvider and pass it a system, the compiled theme:

// theme.ts
import { createSystem, defaultConfig } from '@chakra-ui/react';

export const system = createSystem(defaultConfig);
// provider.tsx
'use client';
import { ChakraProvider } from '@chakra-ui/react';
import { system } from './theme';

export function Provider({ children }: { children: React.ReactNode }) {
  return <ChakraProvider value={system}>{children}</ChakraProvider>;
}

Note the prop name: value={system}, not theme={theme} as in v2. In the Next.js App Router, the provider file must start with 'use client' because it uses React context; render <Provider> from your root layout.tsx.

Chakra’s CLI can also generate ready-made wrappers (npx @chakra-ui/cli snippet add), including a provider, a color mode helper and a toaster, into your project as source files that you then own.


Style props and basic components

import { Box, Button, Heading, Stack, Text } from '@chakra-ui/react';

export function Home() {
  return (
    <Box p={8}>
      <Heading mb={4}>Welcome</Heading>
      <Text mb={4} color="fg.muted">Build accessible React apps</Text>
      <Stack direction="row" gap={4}>
        <Button colorPalette="blue">Primary</Button>
        <Button colorPalette="green" variant="outline">Secondary</Button>
      </Stack>
    </Box>
  );
}

Numbers in spacing props map to the spacing scale (p={8} is 2rem in the default theme), and color strings map to tokens. Using tokens rather than raw values is what keeps a Chakra app consistent, and it is what lets dark mode work, as the dark mode section below shows.

Two renamed props trip up almost everyone coming from v2:

  • colorScheme became colorPalette. In my own type-check against v3, <Button colorScheme="blue"> did not produce a TypeScript error, so the old prop can survive a migration unnoticed while the button renders in the default palette. Search the codebase for colorScheme rather than relying on the compiler.

  • spacing on Stack became gap. This one the compiler does catch:

    error TS2322: Type '{ spacing: number; }' is not assignable to type 'IntrinsicAttributes & StackProps & RefAttributes<HTMLDivElement>'.
      Property 'spacing' does not exist on type 'IntrinsicAttributes & StackProps & RefAttributes<HTMLDivElement>'.

Many boolean props also lost their is prefix: isLoading is loading, isDisabled is disabled, isInvalid is invalid.


Layout primitives

import { Box, Container, Flex, Grid, GridItem, HStack } from '@chakra-ui/react';

export function Layout() {
  return (
    <Container maxW="6xl">
      <Flex justify="space-between" align="center" mb={8}>
        <Box>Logo</Box>
        <HStack gap={4}>
          <Box>Home</Box>
          <Box>About</Box>
        </HStack>
      </Flex>
      <Grid templateColumns={{ base: '1fr', md: 'repeat(3, 1fr)' }} gap={6}>
        <GridItem>Card 1</GridItem>
        <GridItem>Card 2</GridItem>
        <GridItem>Card 3</GridItem>
      </Grid>
    </Container>
  );
}

Box is a div that accepts style props; Flex, Grid, Stack, HStack and VStack are Box with some layout defaults. Use as to change the element when semantics matter, for example <Box as="nav"> or <Stack as="ul">. It is easy to build an entire page out of anonymous divs with this API, and screen reader users then lose the landmarks that header, nav and main would have given them.


Forms with Field and React Hook Form

Chakra v3 replaced FormControl, FormLabel and FormErrorMessage with a compound Field component. It connects the label, input, helper text and error text with the right id and aria-* attributes, so screen readers announce the error when the input is focused.

import { Button, Field, Input, VStack } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';

interface FormData {
  email: string;
  password: string;
}

export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormData>();

  const onSubmit = async (data: FormData) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <VStack gap={4} align="stretch">
        <Field.Root invalid={!!errors.email} required>
          <Field.Label>
            Email <Field.RequiredIndicator />
          </Field.Label>
          <Input type="email" {...register('email', { required: 'Email is required' })} />
          <Field.ErrorText>{errors.email?.message}</Field.ErrorText>
        </Field.Root>

        <Field.Root invalid={!!errors.password}>
          <Field.Label>Password</Field.Label>
          <Input
            type="password"
            {...register('password', {
              required: 'Password is required',
              minLength: { value: 8, message: 'At least 8 characters' },
            })}
          />
          <Field.HelperText>At least 8 characters.</Field.HelperText>
          <Field.ErrorText>{errors.password?.message}</Field.ErrorText>
        </Field.Root>

        <Button type="submit" colorPalette="blue" width="full" loading={isSubmitting}>
          Sign in
        </Button>
      </VStack>
    </form>
  );
}

Field.ErrorText only renders when the field is invalid, so you can always include it. React Hook Form keeps inputs uncontrolled and registers them through refs, which works with Chakra’s Input because it forwards the ref to the underlying <input>. For components that are not native inputs (a custom select, a date picker), use React Hook Form’s Controller instead of register.

noValidate on the form disables the browser’s own validation bubbles, which would otherwise appear before your messages because the input has type="email". required on Field.Root only marks the field visually and for assistive technology; the actual rule lives in register.


Theming with tokens, semantic tokens and recipes

// theme.ts
import { createSystem, defaultConfig, defineConfig, defineRecipe } from '@chakra-ui/react';

const brandButton = defineRecipe({
  base: { fontWeight: 'bold' },
  variants: {
    visual: {
      brand: { bg: 'brand.solid', color: 'brand.contrast' },
    },
  },
});

const config = defineConfig({
  theme: {
    tokens: {
      colors: {
        brand: {
          50: { value: '#e3f2fd' },
          500: { value: '#2196f3' },
          900: { value: '#0d47a1' },
        },
      },
      fonts: {
        heading: { value: 'Georgia, serif' },
        body: { value: 'system-ui, sans-serif' },
      },
    },
    semanticTokens: {
      colors: {
        brand: {
          solid: { value: '{colors.brand.500}' },
          contrast: { value: 'white' },
          fg: { value: { _light: '{colors.brand.900}', _dark: '{colors.brand.50}' } },
        },
        'card.bg': { value: { _light: 'white', _dark: '{colors.gray.900}' } },
      },
    },
    recipes: { brandButton },
  },
});

export const system = createSystem(defaultConfig, config);

The theme has two layers. Tokens are raw values: brand.500 is #2196f3. Semantic tokens are names for a purpose, which can resolve to different tokens per color mode: card.bg is white in light mode and gray.900 in dark mode. Components should use semantic names. If a card says bg="white", you have to find and change it for dark mode; if it says bg="card.bg", dark mode is a change in one place. The default config already includes semantic tokens such as bg, bg.panel, fg and fg.muted, and using them gets you dark mode support for free.

Token values in v3 are objects with a value key ({ value: '#e3f2fd' }), and references use curly braces ('{colors.brand.500}'). Writing plain strings the v2 way (50: '#e3f2fd') is one of the most common reasons a custom color silently does not apply.

Recipes replace v2’s components theme key for styling variants. A recipe defines base styles and named variants; slot recipes do the same for multi-part components. Because createSystem merges your config with defaultConfig, you only specify what changes.


Dark mode

Chakra v3 does not ship useColorMode, useColorModeValue or ColorModeScript. Importing them fails:

error TS2305: Module '"@chakra-ui/react"' has no exported member 'useColorMode'.

Color mode is now driven by a dark class on a parent element, which Chakra’s _dark condition and semantic tokens respond to. The recommended way to manage that class is the next-themes package, which also works outside Next.js; the Chakra CLI’s color-mode snippet wraps it for you. Wired up directly:

// provider.tsx
'use client';
import { ChakraProvider, IconButton } from '@chakra-ui/react';
import { ThemeProvider, useTheme } from 'next-themes';
import { system } from './theme';

export function Provider({ children }: { children: React.ReactNode }) {
  return (
    <ChakraProvider value={system}>
      <ThemeProvider attribute="class" disableTransitionOnChange>
        {children}
      </ThemeProvider>
    </ChakraProvider>
  );
}

export function ColorModeToggle() {
  const { resolvedTheme, setTheme } = useTheme();
  const isDark = resolvedTheme === 'dark';
  return (
    <IconButton
      aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
      variant="ghost"
      onClick={() => setTheme(isDark ? 'light' : 'dark')}
    >
      {isDark ? '☀' : '☾'}
    </IconButton>
  );
}

For one-off differences you can use the _dark condition inline, e.g. <Box bg={{ base: 'white', _dark: 'gray.800' }}>, but semantic tokens scale better.

The flash of the wrong theme on first load is the classic dark-mode bug. next-themes avoids it by injecting a small script that sets the class before the page paints. Because that script changes the <html> element before React hydrates, React warns about a mismatched attribute unless you add suppressHydrationWarning to <html> in your root layout. The other half of the problem is reading the theme during server rendering: resolvedTheme is undefined on the server and on the first client render, so a toggle that renders an icon based on it can mismatch. Render theme-dependent UI only after mount (Chakra’s ClientOnly component does this) or rely on CSS via semantic tokens, which needs no JavaScript at all.

When I look at a Chakra app with dark-mode problems, the cause is rarely the toggle. It is usually dozens of components with hard-coded bg="white" or color="gray.800" from before anyone thought about dark mode, each of which looks fine in light mode. Switching those to semantic tokens early is much cheaper than auditing screens later.


Responsive styles

import { Box, Text } from '@chakra-ui/react';

export function Responsive() {
  return (
    <Box width={{ base: '100%', md: '50%', lg: '25%' }} p={{ base: 4, md: 6, lg: 8 }}>
      <Text fontSize={{ base: 'md', md: 'lg', lg: 'xl' }}>Responsive text</Text>
    </Box>
  );
}

Breakpoints are mobile-first min-width queries: base applies to everything, md from the md breakpoint up. A frequent misunderstanding is to read md: '50%' as “only on medium screens”; it applies to medium and everything larger unless a larger breakpoint overrides it. The array form (fontSize={['sm', 'md', 'lg']}) is shorter but harder to read once you skip breakpoints.

Responsive props compile to CSS media queries, so they work during server rendering. The useBreakpointValue hook, by contrast, runs in JavaScript and knows nothing about the screen during SSR; prefer responsive props for anything that only changes styles.


Compound components: a confirmation dialog

import { Button, CloseButton, Dialog, Portal } from '@chakra-ui/react';

export function ConfirmDelete({ onConfirm }: { onConfirm: () => void }) {
  return (
    <Dialog.Root role="alertdialog">
      <Dialog.Trigger asChild>
        <Button colorPalette="red" variant="outline">Delete</Button>
      </Dialog.Trigger>
      <Portal>
        <Dialog.Backdrop />
        <Dialog.Positioner>
          <Dialog.Content>
            <Dialog.Header>
              <Dialog.Title>Delete project?</Dialog.Title>
            </Dialog.Header>
            <Dialog.Body>This cannot be undone.</Dialog.Body>
            <Dialog.Footer>
              <Dialog.ActionTrigger asChild>
                <Button variant="outline">Cancel</Button>
              </Dialog.ActionTrigger>
              <Button colorPalette="red" onClick={onConfirm}>Delete</Button>
            </Dialog.Footer>
            <Dialog.CloseTrigger asChild>
              <CloseButton size="sm" />
            </Dialog.CloseTrigger>
          </Dialog.Content>
        </Dialog.Positioner>
      </Portal>
    </Dialog.Root>
  );
}

This replaces v2’s Modal, ModalOverlay, ModalContent and the useDisclosure boilerplate. The dialog traps focus while open, returns focus to the trigger when closed, closes on Escape, and labels itself with Dialog.Title. role="alertdialog" tells assistive technology that the dialog requires a response. asChild makes the trigger merge its behavior into your Button rather than wrapping it in a second button element, which would be invalid HTML.


Migrating from Chakra v2

v2v3
extendTheme + <ChakraProvider theme>createSystem(defaultConfig, config) + <ChakraProvider value>
colorSchemecolorPalette
Stack spacinggap
isLoading, isDisabled, isInvalidloading, disabled, invalid
FormControl, FormLabel, FormErrorMessageField.Root, Field.Label, Field.ErrorText
Modal + useDisclosureDialog.* compound components
useColorMode, ColorModeScriptnext-themes (or the CLI color-mode snippet)
framer-motion, @emotion/styled peer depsnot required

Chakra publishes a migration guide covering these changes and more. Plan for it as a real migration on a large app, especially around theme customizations, which do not translate mechanically.


Frequently Asked Questions

Q. How does Chakra compare to MUI?

A. Chakra centers on style props and small composable primitives, and is easy to shape into your own design. MUI follows Material Design and has a larger set of complex components, especially around data grids and date pickers. The best test is to build the most complex screen of your app in both.

Q. Why can’t I import useColorModeValue or extendTheme?

A. They were removed in Chakra v3. Use semantic tokens or the _dark condition instead of useColorModeValue, createSystem instead of extendTheme, and next-themes for switching color modes.

Q. Can Chakra be used with the Next.js App Router?

A. Yes. Put ChakraProvider in a 'use client' provider component rendered from the root layout, add suppressHydrationWarning to <html> when using next-themes, and remember that components using hooks or event handlers must be client components.