Skip to main content

Lists

ShowMore

"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.

Brown the short ribs in batches, then soften onions, carrots and celery in the fat. Deglaze with red wine, cover with stock and braise for three hours, until the meat falls from the bone.

Menu for the harvest supper

Charred broccoli with tahini and a tomato and white bean soup, then braised short ribs over polenta.

Bring a dish if you like: we are short on salads and bread. Pear and ginger crumble closes the night.

  • Braised short ribs

  • Lemon ricotta pancakes

  • Weeknight dal

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-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10

Then 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.10

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/show-more

Its 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.10
import { 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 Collapsible instead to hide a whole section behind its title.
  • Use Pagination instead 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 lines and maxHeight. 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 maxHeight fold unfolds it, whether or not open is 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 sets open. 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 with onShowMore.
  • href: Only with onShowMore.
  • initialCount: Only with items and no onShowMore.
  • items: List modes only: not with maxHeight.
  • lines: Not with maxHeight: fold by height or by lines, not both.
  • loadedLabel: Only with onShowMore.
  • loading: Only with onShowMore.
  • loadingLabel: Only with onShowMore.
  • noMoreLabel: Only with onShowMore.
  • onShowMore: Only for a list that loads more: with items, without initialCount or lines.
  • ordered: List modes only: not with maxHeight.

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. With onShowMore, 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 maxHeight fold unfolds it, whether or not open is 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 sets open. 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 takes items.
  • estimate: Text and height modes only: a list shows its first items.
  • hasMore: Only with onShowMore.
  • href: Only with onShowMore.
  • lines:
  • loadedLabel: Only with onShowMore.
  • loading: Only with onShowMore.
  • loadingLabel: Only with onShowMore.
  • maxHeight: Height mode only: not with items.
  • noMoreLabel: Only with onShowMore.
  • onShowMore: Only for a list that loads more: with items, without initialCount or lines.

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 maxHeight fold unfolds it, whether or not open is 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 sets open. 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 with onShowMore.
  • href: Only with onShowMore.
  • initialCount: Only with items and no onShowMore.
  • items: List modes only: not with lines.
  • loadedLabel: Only with onShowMore.
  • loading: Only with onShowMore.
  • loadingLabel: Only with onShowMore.
  • maxHeight: Not with lines: fold by height or by lines, not both.
  • noMoreLabel: Only with onShowMore.
  • onShowMore: Only for a list that loads more: with items, without initialCount or lines.
  • ordered: List modes only: not with lines.

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 onShowMore instead.
items (required)
Type: readonly ReactNode[]
Every item, in order. Each renders in its own list item; give elements a key. With onShowMore, 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 takes items.
  • estimate: Text and height modes only: a list shows its first items.
  • initialCount: Only with items and no onShowMore.
  • lines:
  • maxHeight: Height mode only: not with items.
  • 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 uses initialCount: <ShowMore items={items} initialCount={5}>.
  • maxLines: Not a prop. Ouro UI uses lines: <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