"Show more" for a long list or a long text. It cuts a list to its first items, clamps text to a number of lines, folds content of any kind (a long post with pictures) to a height under a fade, or grows a list a page at a time from the server, and moves focus to the first newly shown item, so keyboard and screen reader users land on what they asked for. A list that loads more also says how many items now show. The button names what it folds (aria-controls) and, unless it loads more, says whether it is open (aria-expanded). Folded text and content are folded by CSS in the first commit and in server HTML, and the button comes after the browser has laid them out: ShowMore does not guess whether they are longer than the fold. One ResizeObserver reads the layout and adds the button to what overflows. The visible items are in the server HTML. ShowMore draws the list itself: put a border around the list on a wrapping element, and borders between rows on each item.
In Spoonjoy
Spoonjoy, a recipe app, is built with Ouro HX (how).
See it live, no sign-in needed: A recipe · Recipes
In its source: app/components/recipe/SpoonsStrip.tsx · app/components/pantry/RecipeGrid.tsx · app/routes/recipes._index.tsx
Install
Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):
npx shadcn@latest init --base base --preset nova --yes --no-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):
npx shadcn@latest add ourostack/ouro-ui/show-more#v1.3.10Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/show-moreIts tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):
npx shadcn@latest add ourostack/ouro-ui/show-more#v1.3.10 ourostack/ouro-ui/show-more-tests#v1.3.10import { ShowMore } from "@/components/ouro/collapsible/show-more";Paths use shadcn's default @/ alias; the CLI rewrites them to yours.
Example
<ShowMore items={reviews.map((review) => <Review key={review.id} {...review} />)} initialCount={3} />
<ShowMore lines={3}>{recipe.description}</ShowMore>
<ShowMore maxHeight={180}><Post body={post.body} /></ShowMore>
<ShowMore items={rows} hasMore={Boolean(nextCursor)} loading={loading} onShowMore={loadNext} href={nextHref} />
<ShowMore lines={3} open={open} onOpenChange={setOpen}>{recipe.description}</ShowMore>Use instead when
- Use
Collapsibleinstead to hide a whole section behind its title. - Use
Paginationinstead when people need to jump to a page or share its URL.
Props
The props the modes share. Type: ShowMoreProps.
className- Type:
string - Layout only: margins, width, grid placement (
mt-6). Text classes go on a parent so the items inherit them. measureKey- Type:
string | number - A stable name for what this shows (a message id), for
linesandmaxHeight. A row that mounts again with the same key starts from its last measured fit, so it does not mount folded and then unfold. Without it, a mount measures afresh. The browser still measures and corrects it, so a changed width costs one write. ref- Type:
Ref<HTMLDivElement> - Reaches the rendered element.
showMoreLabel- Type:
string - Default:
"Show more" - The button's text while there is more to show. Translate it here.
Modes
ShowMoreProps is a union: pass the props of exactly one mode. Mixing them is a type error.
Height: ShowMoreHeightProps
Long content of any kind folded to maxHeight pixels under a fade, with "Show more" and "Show less".
children(required)- Type:
ReactNode - The content: rich text, pictures, cards. Screen readers always read all of it, and moving keyboard focus into the folded part unfolds it, so nothing focusable is out of sight.
estimate- Type:
ShowMoreEstimate - Not read: ShowMore does not guess whether content is longer than its fold, so this changes nothing. Kept so an app that passes it still compiles.
maxHeight(required)- Type:
number - How tall the content shows while folded, in CSS pixels, with its last part fading out. Content at most a fifth taller shows whole, since a button that reveals a line or two helps nobody. The button appears only when the content is folded.
onOpenChange- Type:
((open: boolean) => void) - Called with the new state when the button is pressed, or when keyboard focus moving below a
maxHeightfold unfolds it, whether or notopenis set. open- Type:
boolean - Whether the content shows in full. With it, the app owns the state: "Show more" and "Show less" ask for the change through
onOpenChange, and nothing changes until the app setsopen. Without it, ShowMore keeps its own state, starting folded. showLessLabel- Type:
string - Default:
"Show less" - The button's text while everything shows. Translate it here.
Not in this mode (typed never):
hasMore: Only withonShowMore.href: Only withonShowMore.initialCount: Only withitemsand noonShowMore.items: List modes only: not withmaxHeight.lines: Not withmaxHeight: fold by height or by lines, not both.loadedLabel: Only withonShowMore.loading: Only withonShowMore.loadingLabel: Only withonShowMore.noMoreLabel: Only withonShowMore.onShowMore: Only for a list that loads more: withitems, withoutinitialCountorlines.ordered: List modes only: not withmaxHeight.
Items: ShowMoreItemsProps
A list cut to its first initialCount items, with "Show more" and "Show less".
initialCount- Type:
number - Default:
5 - How many items show while collapsed.
items(required)- Type:
readonly ReactNode[] - Every item, in order. Each renders in its own list item; give elements a
key. WithonShowMore, append each new page here. onOpenChange- Type:
((open: boolean) => void) - Called with the new state when the button is pressed, or when keyboard focus moving below a
maxHeightfold unfolds it, whether or notopenis set. open- Type:
boolean - Whether the content shows in full. With it, the app owns the state: "Show more" and "Show less" ask for the change through
onOpenChange, and nothing changes until the app setsopen. Without it, ShowMore keeps its own state, starting folded. ordered- Type:
boolean - Default:
false(<ul>) - Renders an
<ol>, for a ranked or numbered list. showLessLabel- Type:
string - Default:
"Show less" - The button's text while everything shows. Translate it here.
Not in this mode (typed never):
children: Text mode only: a list takesitems.estimate: Text and height modes only: a list shows its first items.hasMore: Only withonShowMore.href: Only withonShowMore.lines:loadedLabel: Only withonShowMore.loading: Only withonShowMore.loadingLabel: Only withonShowMore.maxHeight: Height mode only: not withitems.noMoreLabel: Only withonShowMore.onShowMore: Only for a list that loads more: withitems, withoutinitialCountorlines.
Lines: ShowMoreLinesProps
Long text clamped to lines lines, with "Show more" and "Show less".
children(required)- Type:
ReactNode - The text. Screen readers always read all of it.
estimate- Type:
ShowMoreEstimate - Not read: ShowMore does not guess whether content is longer than its fold, so this changes nothing. Kept so an app that passes it still compiles.
lines(required)- Type:
number - How many lines show while collapsed. The button appears only when the text is longer.
onOpenChange- Type:
((open: boolean) => void) - Called with the new state when the button is pressed, or when keyboard focus moving below a
maxHeightfold unfolds it, whether or notopenis set. open- Type:
boolean - Whether the content shows in full. With it, the app owns the state: "Show more" and "Show less" ask for the change through
onOpenChange, and nothing changes until the app setsopen. Without it, ShowMore keeps its own state, starting folded. showLessLabel- Type:
string - Default:
"Show less" - The button's text while everything shows. Translate it here.
Not in this mode (typed never):
hasMore: Only withonShowMore.href: Only withonShowMore.initialCount: Only withitemsand noonShowMore.items: List modes only: not withlines.loadedLabel: Only withonShowMore.loading: Only withonShowMore.loadingLabel: Only withonShowMore.maxHeight: Not withlines: fold by height or by lines, not both.noMoreLabel: Only withonShowMore.onShowMore: Only for a list that loads more: withitems, withoutinitialCountorlines.ordered: List modes only: not withlines.
Load: ShowMoreLoadProps
A list the server grows a page at a time: "Show more" asks for the next page and appends it.
hasMore(required)- Type:
boolean - Whether there is another page. Without one, the button goes away.
href- Type:
string - The next page's URL, so the button is a link that works before JavaScript loads. With JavaScript, pressing it calls
onShowMoreinstead. items(required)- Type:
readonly ReactNode[] - Every item, in order. Each renders in its own list item; give elements a
key. WithonShowMore, append each new page here. loadedLabel- Type:
((count: number) => string) - Default:
(count) => `Showing ${count} items` - What screen readers hear after each page, from the number of items now shown. Name the items and translate it here:
(count) => `Showing ${count} recipes`. loading- Type:
boolean - Shows the button as busy, and keeps it from asking twice.
loadingLabel- Type:
string - Default:
"Loading" - What screen readers hear when loading starts. Translate it here.
noMoreLabel- Type:
string - Default:
"No more items" - What screen readers hear when a page comes back empty and there is no more. Translate it here.
onShowMore(required)- Type:
() => void - Asks for the next page. The button is busy while
loading, and focus moves to the first new item when it arrives. ordered- Type:
boolean - Default:
false(<ul>) - Renders an
<ol>, for a ranked or numbered list.
Not in this mode (typed never):
children: Text mode only: a list takesitems.estimate: Text and height modes only: a list shows its first items.initialCount: Only withitemsand noonShowMore.lines:maxHeight: Height mode only: not withitems.onOpenChange: Not for a list that loads more, which only grows.open: Not for a list that loads more, which only grows.showLessLabel: A list that loads more never shrinks, so it has no "Show less".
Don't use
These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.
limit: Not a prop. Ouro UI usesinitialCount:<ShowMore items={items} initialCount={5}>.maxLines: Not a prop. Ouro UI useslines:<ShowMore lines={3}>.
className is for layout only
Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: ShowMore merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes
Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.
Alignment classes (text-center) warn too: set alignment on a parent element.
Types
ShowMoreEstimate
What folded content holds. Not read: ShowMore decides from the browser's layout, and this leaves the first frame as it is. Kept so an app that passes it still compiles.
interface ShowMoreEstimate {
/** Its plain text. Not read. */
text?: string | undefined;
/** The CSS pixels its pictures and cards take. Not read. */
height?: number | undefined;
}Claim sources
- layout-classes: test:registry/ouro/components/ouro/collapsible/show-more.test.tsx::ShowMore with items > lets app layout classes win and keys rows by their own keys
verify:testExists(registry/ouro/components/ouro/collapsible/show-more.test.tsx::ShowMore with items > lets app layout classes win and keys rows by their own keys)
