Skip to content
Drivn
5 min read

How to Add a Button to a React App

Step-by-step guide to adding a copy-and-own Button to any React project using the Drivn CLI — zero runtime UI deps, typed variants, icon props, and Tailwind.

A button sounds like the last thing you would reach for a library to provide — it is one HTML element. But a production button quickly accumulates concerns: a consistent set of variants and sizes, an icon slot that does not require boilerplate at every call site, a loading state that disables clicks and shows a spinner, and styling that stays in sync with the rest of your design system. Rebuild that on every page and the inconsistencies pile up fast.

Drivn takes a different path from a runtime package. The CLI writes the Button source file directly into your repo, and that source carries no third-party UI dependencies — it is pure React plus Tailwind, with every variant held in a single const styles object. You get a button you can read end-to-end, ship today, and edit without forking anything. Because it is a plain forwardRef component with no hooks, it also renders in a Server Component without a 'use client' boundary.

This guide walks through adding the Button to an existing React app in about ten minutes — install the CLI, copy the component, render it, and reach for variants, sizes, icons, and the loading prop. It works the same in a Vite + React project, a Next.js App Router app, or any framework with Tailwind and TypeScript. For broader setup see the installation page; for the Next.js variant see the Next.js Button guide; for live examples see the Button examples page.

Prerequisites

Before installing the Button, make sure your React project has the three things Drivn assumes: Tailwind CSS v4 installed and processing your CSS, TypeScript configured (the component ships as a .tsx file), and a @/ path alias pointing at your source directory. If you scaffolded with create-next-app, npm create vite, or npx drivn@latest create, all three are already wired up. For a custom setup, open your tsconfig.json and verify the compilerOptions.paths entry; the installation page lists the minimal viable config. The Button uses lucide-react for its loading spinner and any icon props — the CLI adds it automatically during install if it is missing from your package.json. No PostCSS plugins, Babel presets, or Tailwind config changes are required.

Step 1 — Install Drivn via the CLI

Run the CLI from your project root to add the Button source file. The command prompts once for your install directory (defaulting to src/components/ui/), then writes button.tsx and adds lucide-react to your package.json if it is not already present. No global config file is created — the Button is just a TypeScript file in your repo that you can edit like any other component. Confirm the file landed correctly in your editor, then commit the change. If your project uses a monorepo layout or a non-standard path, the CLI docs cover the flags used to target a custom location during install.

1# add the Button to your existing React project
2npx drivn add button
3
4# verify the file was written
5ls src/components/ui/button.tsx

Step 2 — Import and render the Button

Open the page where the Button should live and import it from your UI directory. A single import line gives you the component; the default render is a pill-shaped button with the default variant and md size. Because the Button is a React.forwardRef wrapper around a native <button>, it forwards refs for focus management and spreads every standard button attribute — onClick, type, form, aria-* — straight through to the element. That also means it has no hooks and no 'use client' directive, so it renders inside a Server Component without a client boundary. See the Button docs for the full prop table and the examples page for complete patterns.

1import { Button } from '@/components/ui/button'
2
3export function Toolbar() {
4 return (
5 <Button onClick={() => console.log('clicked')}>
6 Save changes
7 </Button>
8 )
9}

Step 3 — Variants, sizes, icons, and loading

The Button exposes four typed props for appearance and state. variant accepts default, secondary, outline, or destructive; size accepts sm, md, or lg; rounded accepts md or full. Icons are passed as component references — leftIcon={Plus} and rightIcon={ArrowRight} — not as JSX elements, so there is no per-call-site boilerplate. The loading prop shows a spinning Loader2 icon and disables the button so clicks cannot fire during async work. Every one of these is typed from keyof typeof styles, so your editor autocompletes the valid values and rejects typos. The loading state example shows the full async pattern with a try/finally block.

1import { Button } from '@/components/ui/button'
2import { Plus, ArrowRight } from 'lucide-react'
3
4<Button variant="default" leftIcon={Plus}>Add item</Button>
5<Button variant="secondary" rightIcon={ArrowRight}>Continue</Button>
6<Button variant="destructive" size="sm">Delete</Button>
7<Button loading>Saving...</Button>

Step 4 — Customize the styles object

Because the Button lives in your codebase, customization is a source edit rather than a prop API. Open src/components/ui/button.tsx and find the const styles object near the top — it holds every class name the component uses, grouped into base, sizes, variants, and rounded. Add a key to variants for a brand-specific style, adjust sizes for a denser layout, or change the base string to tweak the transition. The types read straight from keyof typeof styles.variants, so adding a key makes it available and autocompleted on the variant prop instantly. To re-theme every button at once instead, change the color tokens it references — bg-foreground, bg-destructive, border-border — in your theme tokens.

1// src/components/ui/button.tsx — styles object
2const styles = {
3 base: cn(
4 'inline-flex items-center justify-center outline-none',
5 'font-semibold transition-all duration-150',
6 'cursor-pointer disabled:opacity-50',
7 'disabled:pointer-events-none'
8 ),
9 sizes: {
10 sm: 'h-8 px-3 text-sm gap-1.5',
11 md: 'h-10 px-4 text-sm gap-2',
12 lg: 'h-12 px-6 text-base gap-2',
13 },
14 variants: {
15 default: 'bg-foreground text-background hover:scale-[1.02]',
16 secondary: cn(
17 'bg-card text-foreground border border-border',
18 'hover:bg-accent hover:border-border'
19 ),
20 outline: 'border border-border text-foreground hover:border-foreground/20',
21 destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
22 },
23 rounded: {
24 md: 'rounded-md',
25 full: 'rounded-full',
26 },
27}
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

Follow Drivn updates
New components, improvements, and guides every release.
Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

Yes. Set your CRA project up with TypeScript and Tailwind v4 first, then run the Drivn CLI. Tailwind v4 has a short install guide, and having a tsconfig.json in place is enough for the CLI to drop the .tsx component into your project. The Button has no dependency on React Router, Next.js, or any framework-specific API — it renders anywhere React and Tailwind render to the DOM.

Pass the icon as a component reference, not a rendered element: leftIcon={Plus} or rightIcon={ArrowRight}, importing the icon from lucide-react. The Button renders it for you, so you avoid writing <Plus className="..." /> at every call site. You can pass an icon on either side, and the gap between icon and label is handled by the size class automatically.

Yes. The Drivn Button is a plain forwardRef component with no useState, no useEffect, and no 'use client' directive, so it renders directly inside a Server Component with no client boundary. Only the parts of your page that need interactivity — the click handlers you attach — pull in client behavior, and Next.js handles that at the call site, not in the Button source.

Setting loading swaps in a spinning Loader2 icon and disables the button via the same disabled path, so clicks cannot fire while async work runs. The label stays visible for screen-reader context. Bind it to a boolean in your component — typically a saving state toggled inside a try/finally — or to a form library's isSubmitting flag so the button re-enables automatically when the request resolves or fails.