React Button Component Usage Examples
Practical React button examples — variants, sizes, icons, loading state, and rounded options using the Drivn Button component in your Next.js project.
Every React codebase converges on the same handful of buttons. A submit at the foot of a form, an Edit and a Delete side by side in a table row, a Cancel next to a Continue in a modal, an oversized Get started on a marketing page. Written by hand they drift apart: one is h-9, the next h-10; one delete is red text, another is a red fill. Drivn's Button fixes that vocabulary in a single component — variant, size, rounded, loading, leftIcon, rightIcon — and types each prop off the local styles object with keyof typeof, so the editor offers the legal values and a typo fails the build instead of quietly rendering nothing.
This page runs through the combinations you will actually ship: the four variants and the job each one is for, the three sizes with their exact heights, icons passed as component references, the two corner radii, and a submit button wired to real async work. Every snippet assumes Drivn is already installed via the CLI and is copy-paste ready as written.
The component underneath is one React.forwardRef wrapped around a native <button> — no Radix, no cva, one Lucide icon for the spinner. Start at the variants if Drivn is new to you, or skip ahead to the loading section if you came for the async recipe.
Variants: default, secondary, outline, destructive
Four variants ship with the Button, each one line in the styles.variants object. default is bg-foreground text-background hover:bg-foreground/90 — maximum contrast against the page, reserved for the primary action. secondary is bg-muted text-foreground hover:bg-muted/80: still filled, but quiet enough to sit beside a primary without competing. outline carries no fill at all — border border-border text-foreground hover:border-foreground/20, so hovering darkens the border rather than the background. destructive is bg-destructive text-destructive-foreground hover:bg-destructive/90, wired to the destructive design token so it tracks whatever your theme defines.
Pick by the weight of the action, not by taste. Keep one default per visible section — a screen with three filled dark buttons has no primary. Reserve destructive for operations that cannot be undone, and gate it behind a Dialog confirmation rather than a bare click. outline belongs on the retreat actions — Cancel, Back, Dismiss — where a fill would pull attention away from the thing you want clicked.
1 import { Button } from '@/components/ui/button' 2 3 <Button variant="default">Save changes</Button> 4 <Button variant="secondary">Preview</Button> 5 <Button variant="outline">Cancel</Button> 6 <Button variant="destructive">Delete account</Button>
Sizes: sm, md, lg
Three sizes cover the common call sites: sm for dense toolbars at 32 pixel height, md for forms and cards at 40 pixel, and lg for marketing hero calls-to-action at 48 pixel. The internal token differences are h-8 px-3 text-sm gap-1.5 for sm, h-10 px-4 text-sm gap-2 for md, and h-12 px-6 text-base gap-2 for lg. Type size steps from 14 pixel to 14 pixel to 16 pixel, not every step.
Mix sizes across the same page sparingly — two sizes visible in the same viewport tends to look intentional, three looks inconsistent. Use md as the baseline for forms (see the Input docs for matching heights) and step up to lg only when the Button is the primary visual element of the section.
1 <Button size="sm">Small</Button> 2 <Button size="md">Medium</Button> 3 <Button size="lg">Large</Button>
Icons: leftIcon and rightIcon
Both leftIcon and rightIcon accept a component reference (leftIcon={Plus}) or a JSX element (leftIcon={<Plus className="text-success" />}). The component form is the expected default — Drivn renders the icon at the size dictated by the active size prop. The JSX form is for the rare case where you need a custom color, a rotated icon, or a non-Lucide glyph.
Import Lucide icons directly — import { Plus, ArrowRight } from 'lucide-react' — and pass the imported reference. Drivn's Button will not place both a spinner and an icon at the same time; when loading is true the spinner takes the leading position and any leftIcon is hidden until loading clears. See the button-loading-state example for the full async pattern.
1 import { Plus, ArrowRight, Download } from 'lucide-react' 2 import { Button } from '@/components/ui/button' 3 4 <Button leftIcon={Plus}>Add item</Button> 5 <Button variant="secondary" rightIcon={ArrowRight}>Continue</Button> 6 <Button variant="outline" leftIcon={Download}>Download</Button>
Rounded: md or full
The rounded prop swaps between a 6-pixel medium radius and a full pill shape. The default is full, which suits marketing pages, CTA rows, and modern product UI. Pass rounded="md" when the Button sits inside a card grid or a dense toolbar where pill buttons would clash with rectangular siblings.
Both corners are controlled by the same Tailwind classes — rounded-md or rounded-full — applied to the base element. Change the token in the styles.rounded object if you want a different default for your whole app; Drivn components live in your repo so this is a one-file edit after running the CLI install. Consistency matters more than the specific radius — pick one default per product and stick with it.
1 <Button rounded="md">Medium radius</Button> 2 <Button rounded="full">Full radius (default)</Button>
Loading state tied to async work
Wire the loading prop to the boolean that tracks your async operation. Internally the Button passes disabled={loading || disabled} down to the native element, and its base class carries disabled:opacity-50 disabled:pointer-events-none — so the button dims, stops taking clicks, and drops out of the tab order for as long as the request is in flight. The leading slot swaps at the same time: leftIcon is skipped whenever loading is true and a <Loader2 className="h-4 w-4 animate-spin" /> renders in its place. Because the element is genuinely disabled rather than merely styled to look that way, assistive tech reports the state without any extra attribute from you. The classic pattern is a local useState<boolean> flipped inside a try/finally around the async call, so the button re-enables even when the request throws.
For forms, prefer the form library's submission state. When using React Hook Form, bind loading={formState.isSubmitting} instead of managing a parallel saving boolean — one source of truth, automatic error recovery, and less code. The dedicated button-loading-state example covers both patterns with full snippets and the React Hook Form version.
1 'use client' 2 import * as React from 'react' 3 import { Button } from '@/components/ui/button' 4 5 export function SubmitButton() { 6 const [saving, setSaving] = React.useState(false) 7 8 const onSubmit = async () => { 9 setSaving(true) 10 try { 11 await fetch('/api/save', { method: 'POST' }) 12 } finally { 13 setSaving(false) 14 } 15 } 16 17 return ( 18 <Button loading={saving} onClick={onSubmit}> 19 Save changes 20 </Button> 21 ) 22 }
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
Frequently asked questions
The variant prop controls color and fill (default, secondary, outline, destructive), while rounded controls corner radius (md or full). They are independent — you can pair any variant with either corner style. For a consistent product design, pick one rounded value and stick with it across the app rather than mixing pill and rounded-medium buttons on the same screen.
Yes. Import the icon from lucide-react and pass the component reference: leftIcon={Plus}. Drivn renders it at the size dictated by the active size prop. If you need a JSX element with custom className or color, pass that instead — the Button accepts either form. Do not wrap the icon in a <span> or extra markup; the Button handles positioning.
Pass loading={true} while the async work runs. The Button forwards disabled={loading || disabled} to the underlying element, so the browser itself stops dispatching clicks and the disabled:pointer-events-none base class removes hover affordances. A try/finally around the async call ensures the button re-enables even on network errors. There is no need to also pass disabled — loading already implies it at the DOM level.
Use md (the default) to match the 40-pixel height of Drivn's Input component. That keeps the submit button flush with the input row and avoids visual jumps between fields and the final action. Step up to lg only if the form is the entire focus of the page, such as a standalone signup, contact, or checkout screen.
Not through a prop — Drivn's Button does not implement asChild polymorphism. The styles object is a module-level const inside button.tsx and is not exported, so the clean path is to edit the file you already own: export styles, then apply cn(styles.base, styles.sizes.md, styles.variants.default, styles.rounded.full) to a Next.js <Link>. If link-shaped buttons are common in your app, adding an as prop to the local component is a one-file change after CLI install.

