Expand button
A show more / show less control, with a wrapper that clamps text to a few lines.
npx shadcn@latest add https://ui.goodtabs.co/r/expand-button.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/expand-button
goodtabs is a hand-picked library of the best resources for people who build on the web: design inspiration, UI components, AI tools, agent skills, fonts, icons and dev utilities. Every listing is chosen by a person, not scraped. One line on what it is, one click to open it. It is made for design engineers, frontend developers, indie hackers and designers who already have 40 tabs open and want the 10 that matter.
A paragraph clamped to three lines. The button toggles the clamp.
With a chevron
Release notes
Search now opens in a command menu with the same shortcut on every page. Results group by category, arrow keys move through them and Enter opens the resource in a new tab. Saved resources sync to local storage until accounts arrive, so clearing site data clears them too. Fonts and icons moved into one category with sub-filters.
Custom labels and a chevron that turns when open, inside a card.
Open by default
Every component reads shadcn theme tokens only, so it matches your project the moment it lands. There are no palette classes and no hex values, which means a change to your globals.css is all it takes to restyle the whole set. Install one with the shadcn CLI, read the agent notes if a model is doing the wiring, and ship.
Starts expanded with defaultOpen. Show less clamps it back.
Usage
import { Expandable, ExpandButton } from "@/components/ui/expand-button"
<Expandable lines={3}>{longText}</Expandable>
<ExpandButton open={open} controls="details" chevron onClick={() => setOpen(!open)} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| lines | number | 3 | How many lines Expandable shows while closed. |
| defaultOpen | boolean | false | Start Expandable open. |
| moreLabel | string | "Show more" | Label while closed. |
| lessLabel | string | "Show less" | Label while open. |
| chevron | boolean | false | Show a chevron that turns when open. |
| open | boolean | On ExpandButton alone: the state it reflects, as aria-expanded. | |
| controls | string | On ExpandButton alone: id of the block it expands, as aria-controls. |
Source
expand-button/expand-button.tsx
"use client";
import * as React from "react";
import { ChevronDownIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ExpandButtonProps = Omit<React.ComponentProps<typeof Button>, "children"> & {
/** Whether the controlled block is open. Sets aria-expanded. */
open: boolean;
/** id of the block this button expands. Sets aria-controls. */
controls?: string;
/** Label while closed. */
moreLabel?: string;
/** Label while open. */
lessLabel?: string;
/** Show a chevron that turns when open. */
chevron?: boolean;
};
/** The show more / show less control. Use it alone with your own state, or let Expandable render it. */
export function ExpandButton({
open,
controls,
moreLabel = "Show more",
lessLabel = "Show less",
chevron = false,
variant = "ghost",
size = "sm",
className,
...props
}: ExpandButtonProps) {
return (
<Button
type="button"
variant={variant}
size={size}
aria-expanded={open}
aria-controls={controls}
data-state={open ? "open" : "closed"}
className={cn("text-muted-foreground hover:text-foreground", className)}
{...props}
>
{open ? lessLabel : moreLabel}
{chevron && (
<ChevronDownIcon
data-icon="inline-end"
aria-hidden="true"
className={cn(
"transition-transform duration-150 ease-out motion-reduce:transition-none",
open && "rotate-180",
)}
/>
)}
</Button>
);
}
type ExpandableProps = React.ComponentProps<"div"> & {
/** How many lines show while closed. */
lines?: number;
/** Start open. */
defaultOpen?: boolean;
/** Label while closed. */
moreLabel?: string;
/** Label while open. */
lessLabel?: string;
/** Show a chevron on the button. */
chevron?: boolean;
};
/**
* Clamps its children to a number of lines and adds a show more / show less button.
* The button only renders when the text is actually longer than the clamp.
*/
export function Expandable({
lines = 3,
defaultOpen = false,
moreLabel,
lessLabel,
chevron,
className,
style,
children,
...props
}: ExpandableProps) {
const id = React.useId();
const ref = React.useRef<HTMLDivElement>(null);
const [open, setOpen] = React.useState(defaultOpen);
const [overflows, setOverflows] = React.useState(defaultOpen);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new ResizeObserver(() => {
// While open the clamp is off, so keep the button; measure only in the clamped state.
if (el.dataset.state === "open") return;
setOverflows(el.scrollHeight > el.clientHeight + 1);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div data-slot="expandable" className={cn("flex flex-col items-start gap-1", className)} {...props}>
<div
ref={ref}
id={id}
data-state={open ? "open" : "closed"}
className={cn("text-sm text-muted-foreground", open ? "line-clamp-none" : "line-clamp-(--lines)")}
style={{ "--lines": lines, ...style } as React.CSSProperties}
>
{children}
</div>
{overflows && (
<ExpandButton
open={open}
controls={id}
moreLabel={moreLabel}
lessLabel={lessLabel}
chevron={chevron}
onClick={() => setOpen((o) => !o)}
className="-ml-2.5"
/>
)}
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/expand-button/prompt.md, or the Copy AI prompt button above.
When to use
- Long descriptions, bios and release notes in a card or list where space is tight
- Anywhere the first few lines are enough for most people and the rest is on demand
- As a plain toggle for your own collapsible block, through ExpandButton alone
When not to use
- For hiding whole sections with headings: use an accordion or collapsible
- When the content is short; the button does not render if nothing is clamped
- For truncating a single line such as a title: use truncate
Agent notes
- Expandable owns its state and needs no props beyond children; it is a client component, the parent can stay server
- The button only appears when the text really overflows the clamp, measured with a ResizeObserver
- The clamp is CSS line-clamp, so children should be inline text or inline elements, not block layouts
- ExpandButton alone takes open and controls and an onClick, so its parent must be a client component ("use client")