Autoheight
Dropdown compositions that hug content: search, titles, icons, checks, dividers, and sticky footers.
Drill-in
Open Filter and choose Model or Browser. The pane slides over and the menu height eases to the new list in 200ms, then back when you return.
Header, search and footer
The height counts the back row, search field and footer along with the list, so pinned chrome never jumps as the pane changes.
import { useState } from "react"; import { Button, ChevronsUpDownIcon, DropdownMenu, DropdownMenuContent, DropdownMenuFooter, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSearch, DropdownMenuSub, DropdownMenuSubPanel, DropdownMenuSubTrigger, DropdownMenuTrigger, Flag, FlagIcon, PlusIcon, type FlagCountry, } from "@smallest-inc/ui"; const LANGUAGES: { name: string; country: FlagCountry }[] = [ { name: "Arabic", country: "SA" }, { name: "Bengali", country: "BD" }, { name: "Chinese", country: "CN" }, { name: "Dutch", country: "NL" }, { name: "English", country: "GB" }, { name: "French", country: "FR" }, { name: "German", country: "DE" }, { name: "Hindi", country: "IN" }, { name: "Italian", country: "IT" }, { name: "Japanese", country: "JP" }, { name: "Korean", country: "KR" }, { name: "Portuguese", country: "PT" }, { name: "Spanish", country: "ES" }, { name: "Tamil", country: "IN" }, ]; const OPERATORS = ["is", "is not"]; function OperatorSelect({ value, onValueChange }: { value: string; onValueChange: (value: string) => void }) { return ( <DropdownMenu modal={false}> <DropdownMenuTrigger render={ <Button variant="transparent" size="sm" className="gap-1.5 rounded-lg px-2 font-normal text-fg-quaternary hover:bg-neutral-150 hover:text-fg" /> } > {value} <ChevronsUpDownIcon radius="0" className="size-[18px] text-fg-quaternary" /> </DropdownMenuTrigger> <DropdownMenuContent align="end" sideOffset={6} className="w-36"> <DropdownMenuRadioGroup value={value} onValueChange={(next) => onValueChange(String(next))}> {OPERATORS.map((operator) => ( <DropdownMenuRadioItem key={operator} value={operator}> {operator} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> ); } export default function DropdownMenuSubmenuHeader() { const [query, setQuery] = useState(""); const [operator, setOperator] = useState("is"); const [language, setLanguage] = useState("English"); const matches = LANGUAGES.filter((item) => item.name.toLowerCase().includes(query.trim().toLowerCase())); return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Filter</DropdownMenuTrigger> <DropdownMenuContent className="w-72"> <DropdownMenuLabel>Filter agents by</DropdownMenuLabel> <DropdownMenuSub name="language" variant="inline"> <DropdownMenuSubTrigger leading={<FlagIcon />} trailing={<span className="text-fg-tertiary">{operator} {language}</span>} > Language </DropdownMenuSubTrigger> <DropdownMenuSubPanel title="Language" aside={<OperatorSelect value={operator} onValueChange={setOperator} />} search={ <DropdownMenuSearch placeholder="Search languages" aria-label="Search languages" value={query} onChange={(event) => setQuery(event.target.value)} /> } > <DropdownMenuRadioGroup value={language} onValueChange={(next) => setLanguage(String(next))}> {matches.map((item) => ( <DropdownMenuRadioItem key={item.name} value={item.name} leading={<Flag country={item.country} title={item.name} />} > {item.name} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> <DropdownMenuFooter> <DropdownMenuItem leading={<PlusIcon />}>Add custom language</DropdownMenuItem> </DropdownMenuFooter> </DropdownMenuSubPanel> </DropdownMenuSub> </DropdownMenuContent> </DropdownMenu> ); }
Search
As the search narrows the list, the menu shrinks to hug the matches and grows back when the query clears.
import { useState } from "react"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSearch, DropdownMenuTrigger, } from "@smallest-inc/ui"; const VOICES = [ "Aria, warm", "Arjun, calm", "Elena, bright", "Hana, soft", "Kofi, deep", "Lucas, energetic", "Mei, clear", "Omar, steady", "Priya, friendly", "Rohan, confident", "Sofia, gentle", "Tomás, relaxed", ]; export default function DropdownMenuSearchExample() { const [query, setQuery] = useState(""); const [voice, setVoice] = useState("Aria, warm"); const matches = VOICES.filter((item) => item.toLowerCase().includes(query.trim().toLowerCase())); return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Voice</DropdownMenuTrigger> <DropdownMenuContent className="w-64"> <DropdownMenuSearch placeholder="Search voices" aria-label="Search voices" value={query} onChange={(event) => setQuery(event.target.value)} /> {matches.length === 0 ? <DropdownMenuLabel>No voices match “{query}”</DropdownMenuLabel> : null} <DropdownMenuRadioGroup value={voice} onValueChange={(next) => setVoice(String(next))}> {matches.map((item) => ( <DropdownMenuRadioItem key={item} value={item}> {item} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> ); }
Maximum height
Height is capped at 500px or the space available. Past the cap the list scrolls with a fade while the search and footer stay pinned.
import { useState } from "react"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuFooter, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSearch, DropdownMenuTrigger, Flag, type FlagCountry, } from "@smallest-inc/ui"; const LANGUAGES: { name: string; country: FlagCountry }[] = [ { name: "Arabic", country: "SA" }, { name: "Bengali", country: "BD" }, { name: "Chinese", country: "CN" }, { name: "English", country: "GB" }, { name: "French", country: "FR" }, { name: "German", country: "DE" }, { name: "Hindi", country: "IN" }, { name: "Indonesian", country: "ID" }, { name: "Japanese", country: "JP" }, { name: "Korean", country: "KR" }, { name: "Spanish", country: "ES" }, { name: "Tamil", country: "IN" }, { name: "Thai", country: "TH" }, { name: "Vietnamese", country: "VN" }, ]; export default function DropdownMenuFooterCaption() { const [query, setQuery] = useState(""); const [language, setLanguage] = useState("English"); const matches = LANGUAGES.filter((item) => item.name.toLowerCase().includes(query.trim().toLowerCase())); return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="secondary" />}>Language</DropdownMenuTrigger> <DropdownMenuContent className="w-72"> <DropdownMenuSearch placeholder="Search languages" aria-label="Search languages" value={query} onChange={(event) => setQuery(event.target.value)} /> <DropdownMenuRadioGroup value={language} onValueChange={(next) => setLanguage(String(next))}> {matches.map((item) => ( <DropdownMenuRadioItem key={item.name} value={item.name} leading={<Flag country={item.country} title={item.name} />} > {item.name} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> <DropdownMenuFooter variant="caption">Can’t find a language? Request it from support.</DropdownMenuFooter> </DropdownMenuContent> </DropdownMenu> ); }
Best practices
When to use
- Use when a bounded surface switches between known content states (drill-in panes, filtered lists) and the height change should feel continuous.
- Avoid it for long, frequently changing lists where scrolling is clearer and less distracting.
Behavior
- Dropdown height counts the header, search, list and footer, and caps at 500px or the space available, after which the list scrolls with a fade.
- Pane changes ease over 200ms; respect reduced motion and never clip focus rings or interactive content.