The shipping selector on the order screen needs four carriers, each with its own service levels. Express should only appear for orders over $100, some services are domestic only, and the recommended option needs a badge. Built from standard parts, that becomes several Radio Groups, separate Text components for descriptions, and visibility rules spread across all of them.
Retool's RadioGroup handles a flat list of options well. This screen needed grouping, conditional rules, and option metadata inside one control. We built a custom component for it, written in TypeScript, and published it on GitHub under the MIT license.
What it does
The component takes one options array and renders it as a single selection control.
Single or multiple selection. Radio behavior by default, checkbox behavior with
multipleSelect.Four layouts. Vertical, horizontal, grid (with
gridColumns), or justified.Button position. Left, right, top, or bottom of each option.
Button shapes. Bullet, square, rounded square, or diamond.
Rich options. Titles, descriptions, icons, badges with their own color, and optional HTML rendering.
Group headers. Section headers inside the list, so large sets stay readable.
Conditional display. Each option can carry a
showIfexpression evaluated against app state.Tooltips on truncation. Long text gets a tooltip only when it is cut off.
Styling. Colors, font sizes, alignment, and line clamping are all properties.
Keyboard access. Tab, Enter, and Space work, with ARIA labels for screen readers.
Install and deploy
You need Node.js 20 or later and an API access token with read and write scopes for Custom Component Libraries.
git clone https://github.com/StackdropCO/custom-radio-group-retool-component.git
cd custom-radio-group-retool-component
npm install
npx retool-ccl loginRun development mode while you work. Changes sync into your Retool organization on save.
npm run devDeploy when it is ready. Each deploy creates an immutable version of the library.
npm run deployIn the app's Custom Component settings, switch from dev to the version you just published. For self-hosted or multi-instance setups, npx retool-ccl sync distributes the library across instances.
Configure the options
Drag the RadioGroup onto the canvas from the Custom Components section. Set options to a static array or bind it to a query. This is the shipping selector from the opening, with two carriers shown:
{{
[
{ "type": "header", "title": "USPS" },
{
"id": "usps_standard",
"title": "USPS Priority Mail",
"description": "2-3 business days",
"showIf": "{{ orderDetails.data.country === 'US' }}"
},
{
"id": "usps_express",
"title": "USPS Express",
"description": "1-2 business days",
"badge": "Recommended",
"badgeColor": "#10b981",
"showIf": "{{ orderDetails.data.total > 100 && orderDetails.data.country === 'US' }}"
},
{ "type": "header", "title": "FedEx" },
{
"id": "fedex_ground",
"title": "FedEx Ground",
"description": "3-5 business days"
},
{
"id": "fedex_overnight",
"title": "FedEx Overnight",
"description": "Next business day",
"badge": "Fastest",
"badgeColor": "#3b82f6",
"showIf": "{{ orderDetails.data.total > 100 }}"
}
]
}}Each option needs an id and a title. Everything else is optional: description, disabled, renderAsHtml, icon, iconPosition, badge, badgeColor, and showIf. A header is any entry with "type": "header" and a title.
The showIf value is an expression string, "true" by default. It can reference any component or query in the app, such as {{ switch1.value }} or {{ dropdown1.value === 'premium' }}.
Then set the rest in the inspector:
defaultValuefor single selection, ordefaultValuesfor multiple.layoutandbuttonPosition.Colors, font sizes, and
lineClampfor long descriptions.
Build the options from a query
Hardcoded options go stale the day a carrier changes a service. In production, keep the services in a table and build the array in a query transformer. This query returns one row per service, ordered by carrier:
select id, carrier, title, description, is_recommended
from shipping_services
where active = true
order by carrier, sort_order;The transformer turns those rows into options, adding a header each time the carrier changes:
// Transformer on the getShippingServices query
const rows = formatDataAsArray(data);
const options = [];
let carrier = null;
for (const row of rows) {
if (row.carrier !== carrier) {
carrier = row.carrier;
options.push({ type: 'header', title: carrier });
}
options.push({
id: row.id,
title: row.title,
description: row.description,
badge: row.is_recommended ? 'Recommended' : undefined,
});
}
return options;Set options to {{ getShippingServices.data }}. The operations team can now add or retire a service in the table, with no change to the app.
Read the selection
The component exposes the selection as state, and fires selectionChange whenever it changes. Attach a handler to that event to recalculate a price or run a query.
// Single select
const selectedShipping = radioGroup1.selectedValue;
// Returns: "fedex_overnight"
// Multiple select (feature selection, notification preferences)
const selectedNotifications = radioGroup1.selectedValues;
// Returns: ["email", "sms", "push"]In a binding, the same values read as {{ radioGroup1.selectedValue }} and {{ radioGroup1.selectedValues }}.
Before production
Pin the app to a published version of the library, never the dev version.
Check that every
showIfexpression references a component or query that exists in the app.Test keyboard navigation with Tab, Enter, and Space.
Keep option lists under 100 items, the size the component is tuned for.
What is next
On the roadmap, not committed: built-in search for lists of 20 or more options, and validation rules for required selections and minimum or maximum counts.
Our view
We recommend this component when one choice depends on order data and needs grouping or badges, because one options array is easier to review. Visibility rules spread across several components are harder to check. For a plain list of options, keep Retool's built-in RadioGroup, because it already inherits the app's theme and form behavior.
Issues and pull requests are welcome. The wider collection is in awesome-retool-components, and what is a custom Retool component covers when building one is worth it.