A hierarchical combobox for selecting property paths within JSON objects.
const sampleData = { id: '123', email: 'user@example.com', firstName: 'John', lastName: 'Doe', customAttributes: { 'department': 'Engineering', 'Job Title': 'Software Engineer', 'office.location': 'San Francisco', }, }; function Example() { const [selectedValue, setSelectedValue] = React.useState(null); return ( <JsonPathSelector data={sampleData} selectedValue={selectedValue} onSelectionChange={setSelectedValue} /> ); }
| Prop | Type | Default |
|---|---|---|
data | JsonObject | Required |
selectedValue | string[] | null | undefined (controlled) |
defaultSelectedValue | string[] | null | null (uncontrolled) |
onSelectionChange | (selectedKeys: string[] | null) => void | |
placeholder | string | 'Select an attribute' |
label | string | 'Attributes' |
disabled | boolean | false |
align | 'start' | 'center' | 'end' | 'start' |
emptyMessage | string | 'No data to display' |
notFoundMessage | string | 'No attributes found' |
A simple selector with a flat object structure.
const userData = { email: 'user@example.com', firstName: 'John', lastName: 'Doe', username: 'johndoe', }; function Example() { const [selectedValue, setSelectedValue] = React.useState(null); return ( <Flex direction="column" gap="2" width="320px"> <JsonPathSelector data={userData} selectedValue={selectedValue} onSelectionChange={setSelectedValue} /> {selectedValue && ( <Text size="2" color="gray"> Selected: {selectedValue.join('.')} </Text> )} </Flex> ); }
The component automatically handles nested objects, displaying them as expandable/collapsible tree nodes. It also supports complex property names with spaces, dots, and URNs.
const complexData = { 'user.id': 'usr_123', 'urn:workos:user:email': 'user@example.com', 'profile': { 'First Name': 'John', 'Last Name': 'Doe', 'avatar.url': 'https://example.com/avatar.jpg', 'preferences': { notifications: { email: { frequency: 'daily', categories: { security: true, marketing: false, product: true, }, }, push: { enabled: true, }, }, theme: 'dark', }, }, 'employment': { 'Company Name': 'ACME Corp', 'urn:workos:department': 'Engineering', 'position': { title: 'Senior Engineer', level: 'IC4', team: { name: 'Platform', location: { office: 'San Francisco HQ', building: { name: 'Building A', address: { street: '123 Main St', coordinates: { latitude: 37.7749, longitude: -122.4194, }, }, }, }, }, }, }, }; function Example() { const [selectedValue, setSelectedValue] = React.useState(null); return ( <Flex direction="column" gap="2" width="320px"> <JsonPathSelector data={complexData} selectedValue={selectedValue} onSelectionChange={setSelectedValue} /> {selectedValue && ( <Text size="2" color="gray"> Path: {selectedValue.join(' → ')} </Text> )} </Flex> ); }
When no data is available (null, undefined, or empty object), the JsonPathSelector displays an empty state message. When data exists but search returns no results, it shows a not found message. You can customize both messages using the emptyMessage and notFoundMessage props.
function Example() { const [selectedValue1, setSelectedValue1] = React.useState(null); const [selectedValue2, setSelectedValue2] = React.useState(null); const [selectedValue3, setSelectedValue3] = React.useState(null); const sampleData = { firstName: 'John', lastName: 'Doe', email: 'john@example.com', }; return ( <Flex direction="column" gap="4" width="320px"> <Flex direction="column" gap="2"> <Text size="2" weight="medium"> Default Empty Message </Text> <JsonPathSelector align="start" data={{}} selectedValue={selectedValue1} onSelectionChange={setSelectedValue1} /> </Flex> <Flex direction="column" gap="2"> <Text size="2" weight="medium"> Custom Empty Message </Text> <JsonPathSelector align="start" data={{}} emptyMessage="No attributes configured yet" placeholder="Select an attribute" selectedValue={selectedValue2} onSelectionChange={setSelectedValue2} /> </Flex> <Flex direction="column" gap="2"> <Text size="2" weight="medium"> Custom Not Found Message </Text> <JsonPathSelector align="start" data={sampleData} notFoundMessage="No matching attributes" placeholder="Select an attribute" selectedValue={selectedValue3} onSelectionChange={setSelectedValue3} /> </Flex> </Flex> ); }
Disable the selector to prevent user interaction.
const sampleData = { id: '123', status: 'active', createdAt: '2024-01-15', }; function Example() { const [selectedValue, setSelectedValue] = React.useState(['status']); const [emptySelectedValue, setEmptySelectedValue] = React.useState(null); return ( <Flex direction="column" gap="4" width="320px"> <Flex direction="column" gap="2"> <JsonPathSelector data={{}} selectedValue={emptySelectedValue} onSelectionChange={setEmptySelectedValue} disabled /> </Flex> <Flex direction="column" gap="2"> <JsonPathSelector data={sampleData} selectedValue={selectedValue} onSelectionChange={setSelectedValue} disabled /> </Flex> </Flex> ); }
This example stress tests the component with deeply nested data (5+ levels), many items at each level, and varying text lengths to demonstrate how it handles complex real-world scenarios.
const largeDataset = { // Short names 'id': 'usr_123', 'uid': 'u_456', 'ref': 'ref_789', // Medium length names 'emailAddress': 'user@example.com', 'displayName': 'John Doe', 'username': 'johndoe', 'phoneNumber': '+1-555-0123', // Long and complex names 'urn:ietf:params:scim:schemas:core:2.0:User': 'user_schema', 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User': 'enterprise_schema', 'urn:workos:directory:attribute:custom:department:engineering:team:platform': 'platform_team', // Very long parent attribute with children 'urn:ietf:params:scim:schemas:extension:enterprise:workos:2.0:User:CustomAttributes': { customField1: 'value1', customField2: 'value2', customField3: 'value3', nestedCustomAttributes: { attribute1: 'nested_value1', attribute2: 'nested_value2', veryLongAttributeNameThatDemonstratesTextWrappingAndOverflow: 'value', }, }, // Deep nesting with many items 'personalInformation': { basicDetails: { firstName: 'John', middleName: 'Michael', lastName: 'Doe', preferredName: 'Johnny', suffix: 'Jr.', title: 'Mr.', dateOfBirth: '1990-01-15', placeOfBirth: 'San Francisco, CA', nationality: 'American', maritalStatus: 'Single', }, contactInformation: { primaryEmail: 'john.doe@example.com', secondaryEmail: 'johnny@personal.com', workEmail: 'j.doe@company.com', mobilePhone: '+1-555-0100', homePhone: '+1-555-0101', workPhone: '+1-555-0102', emergencyContact: { name: 'Jane Doe', relationship: 'Sister', primaryPhone: '+1-555-0200', secondaryPhone: '+1-555-0201', email: 'jane.doe@example.com', address: { street: '456 Oak Avenue', unit: 'Apt 5B', city: 'Oakland', state: 'California', postalCode: '94612', country: 'United States', }, }, }, addresses: { homeAddress: { streetAddress: '123 Main Street', apartmentNumber: 'Unit 4A', city: 'San Francisco', state: 'California', zipCode: '94102', country: 'United States', coordinates: { latitude: 37.7749, longitude: -122.4194, accuracy: 'high', }, }, mailingAddress: { streetAddress: 'P.O. Box 12345', city: 'San Francisco', state: 'California', zipCode: '94103', country: 'United States', }, previousAddresses: { address1: { street: '789 Pine Street', city: 'Berkeley', state: 'California', from: '2015-01', to: '2018-12', }, address2: { street: '321 Elm Street', city: 'Palo Alto', state: 'California', from: '2012-01', to: '2015-01', }, }, }, }, 'employmentInformation': { currentEmployment: { 'companyName': 'ACME Corporation', 'urn:workos:company:id': 'comp_0123456789', 'position': { 'jobTitle': 'Senior Software Engineer', 'Job Level': 'IC4', 'employmentType': 'Full-Time', 'department': { name: 'Engineering', division: 'Product Development', costCenter: 'CC-1000', team: { name: 'Platform Infrastructure', subTeam: 'API Services', location: { officeName: 'San Francisco Headquarters', buildingName: 'Building A', floor: '5th Floor', desk: 'Desk A-523', address: { street: '100 Market Street', suite: 'Suite 500', city: 'San Francisco', state: 'California', postalCode: '94105', coordinates: { latitude: 37.7942, longitude: -122.3954, timezone: 'America/Los_Angeles', }, }, }, }, }, 'responsibilities': { primary: 'API Development', secondary: 'Code Review', tertiary: 'Mentoring', }, 'manager': { name: 'Jane Smith', email: 'jane.smith@company.com', title: 'Engineering Manager', department: 'Engineering', }, }, 'compensation': { baseSalary: 150000, currency: 'USD', paymentFrequency: 'Biweekly', bonus: { targetAmount: 20000, performanceMultiplier: 1.0, }, }, 'startDate': '2020-01-15', 'employeeId': 'EMP-12345', }, employmentHistory: { previousEmployer1: { companyName: 'Tech Startup Inc', position: 'Software Engineer', startDate: '2018-06', endDate: '2020-01', reasonForLeaving: 'Career Growth', }, previousEmployer2: { companyName: 'Big Tech Co', position: 'Junior Developer', startDate: '2015-07', endDate: '2018-06', reasonForLeaving: 'Relocation', }, }, }, 'systemPreferences': { notificationSettings: { emailNotifications: { enabled: true, frequency: 'daily', categories: { securityAlerts: true, systemUpdates: true, marketingEmails: false, productAnnouncements: true, teamMentions: true, directMessages: true, }, }, pushNotifications: { enabled: true, devices: { mobile: true, desktop: true, browser: false, }, }, }, displayPreferences: { theme: 'dark', language: 'en-US', timezone: 'America/Los_Angeles', dateFormat: 'MM/DD/YYYY', timeFormat: '12h', }, }, }; function Example() { const [selectedValue, setSelectedValue] = React.useState([ 'employmentInformation', 'currentEmployment', 'position', 'department', 'team', 'location', 'address', 'coordinates', 'timezone', ]); return ( <Flex direction="column" gap="2" width="320px"> <JsonPathSelector data={largeDataset} selectedValue={selectedValue} onSelectionChange={setSelectedValue} /> {selectedValue && ( <Text size="2" color="gray"> Selected: {selectedValue.join(' → ')} </Text> )} </Flex> ); }
The JsonPathSelector can be used as either a controlled or uncontrolled component.
const sampleData = { id: '123', name: 'Product A', price: 29.99, inStock: true, }; function Example() { // Controlled const [controlledValue, setControlledValue] = React.useState(['name']); // Uncontrolled const [lastUncontrolledValue, setLastUncontrolledValue] = React.useState(null); return ( <Flex direction="column" gap="4" width="320px"> <Flex direction="column" gap="2"> <Text size="2" weight="medium"> Controlled </Text> <JsonPathSelector data={sampleData} selectedValue={controlledValue} onSelectionChange={setControlledValue} /> <Button size="1" onClick={() => setControlledValue(['price'])}> Select "price" </Button> </Flex> <Flex direction="column" gap="2"> <Text size="2" weight="medium"> Uncontrolled </Text> <JsonPathSelector data={sampleData} defaultSelectedValue={['id']} onSelectionChange={setLastUncontrolledValue} /> {lastUncontrolledValue && ( <Text size="1" color="gray"> Last selected: {lastUncontrolledValue.join('.')} </Text> )} </Flex> </Flex> ); }
Multiple selectors can be arranged in a grid layout for attribute mapping scenarios.
const idpSchema = { id: 'usr_123', email: 'user@example.com', username: 'johndoe', profile: { firstName: 'John', lastName: 'Doe', displayName: 'John Doe', }, attributes: { department: 'Engineering', title: 'Software Engineer', location: 'San Francisco', }, }; const attributeMappings = [ { key: 'firstName', required: true }, { key: 'lastName', required: true }, { key: 'email', required: true }, { key: 'department', required: false }, { key: 'jobTitle', required: false }, ]; function Example() { const [mappings, setMappings] = React.useState({}); const [submitted, setSubmitted] = React.useState(null); const handleChange = (key, value) => { setMappings((prev) => ({ ...prev, [key]: value })); }; const handleSubmit = (e) => { e.preventDefault(); const result = Object.entries(mappings) .filter(([, value]) => value) .map(([key, value]) => `${key}: ${value.join('.')}`); setSubmitted(result.join(', ')); }; return ( <Flex direction="column" gap="4" width="100%"> <Box asChild style={{ border: '1px solid var(--gray-a5)', borderRadius: 'var(--radius-4)', }} > <form onSubmit={handleSubmit}> {/* Header */} <Box style={{ display: 'grid', gridTemplateColumns: '1fr 30px 1fr', gap: 'var(--space-3)', padding: 'var(--space-3)', backgroundColor: 'var(--gray-a2)', borderBottom: '1px solid var(--gray-a5)', }} > <Text size="2" weight="bold"> Attribute name </Text> <Box /> <Text size="2" weight="bold"> IdP field name </Text> </Box> {/* Rows */} <Box> {attributeMappings.map((attr, index) => ( <Box key={attr.key} style={{ display: 'grid', gridTemplateColumns: '1fr 30px 1fr', gap: 'var(--space-3)', padding: 'var(--space-3)', alignItems: 'center', borderBottom: index < attributeMappings.length - 1 ? '1px solid var(--gray-a5)' : 'none', }} > <Flex align="center" gap="2"> <Text size="2">{attr.key}</Text> {attr.required && <Badge>Required</Badge>} </Flex> <Flex align="center" justify="center"> <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg" > <path d="M8.14645 3.14645C8.34171 2.95118 8.65829 2.95118 8.85355 3.14645L12.8536 7.14645C13.0488 7.34171 13.0488 7.65829 12.8536 7.85355L8.85355 11.8536C8.65829 12.0488 8.34171 12.0488 8.14645 11.8536C7.95118 11.6583 7.95118 11.3417 8.14645 11.1464L11.2929 8H2.5C2.22386 8 2 7.77614 2 7.5C2 7.22386 2.22386 7 2.5 7H11.2929L8.14645 3.85355C7.95118 3.65829 7.95118 3.34171 8.14645 3.14645Z" fill="var(--gray-a10)" fillRule="evenodd" clipRule="evenodd" /> </svg> </Flex> <JsonPathSelector align="end" data={idpSchema} placeholder="Select source attribute" selectedValue={mappings[attr.key] || null} onSelectionChange={(value) => handleChange(attr.key, value)} /> </Box> ))} </Box> <Box style={{ padding: 'var(--space-3)', borderTop: '1px solid var(--gray-a5)', }} > <Button type="submit">Save mappings</Button> </Box> </form> </Box> {submitted && ( <Callout.Root> <Callout.Text>Saved: {submitted}</Callout.Text> </Callout.Root> )} </Flex> ); }
A comprehensive example showing multiple selectors in a Dialog with react-hook-form integration, scrollable body, and fixed header.
() => { const [open, setOpen] = React.useState(false); const [savedValues, setSavedValues] = React.useState(null); const idpAttributeSchema = { id: 'usr_01HQZV3K7J8M9N2P3Q4R5S6T7V', email: 'john.doe@example.com', username: 'john.doe', firstName: 'John', lastName: 'Doe', profile: { displayName: 'John Doe', title: 'Senior Software Engineer', department: 'Engineering', manager: 'jane.smith@example.com', phoneNumber: '+1-555-0123', }, employment: { employeeId: 'EMP-12345', startDate: '2020-01-15', status: 'active', location: { office: 'San Francisco', building: 'Building A', floor: '3', }, }, customAttributes: { 'Cost Center': 'CC-100', 'Security Clearance': 'Level 2', 'Preferred Language': 'English', }, groups: [ { id: 'grp_123', name: 'Engineering' }, { id: 'grp_456', name: 'Full-Time' }, ], }; const attributeMappings = [ { key: 'idpId', label: 'IdP ID', required: true }, { key: 'firstName', label: 'First Name', required: true }, { key: 'lastName', label: 'Last Name', required: true }, { key: 'email', label: 'Email', required: true }, { key: 'username', label: 'Username', required: true }, { key: 'jobTitle', label: 'Job Title', required: false }, { key: 'department', label: 'Department', required: false }, { key: 'groups', label: 'Groups', required: false }, ]; const { control, handleSubmit, formState: { errors }, } = useForm(); const jsonPathRefs = React.useRef([]); const onSubmit = (values) => { setSavedValues(values); setOpen(false); }; const handleRowClick = (index, e) => { e.currentTarget.querySelector('.JsonPathSelectorTrigger').click(); }; return ( <Flex direction="column" gap="4"> <Button onClick={() => setOpen(true)}>Edit attribute mapping</Button> {savedValues && ( <Callout.Root color="green"> <Callout.Text> Saved {Object.keys(savedValues).length} attribute mappings </Callout.Text> </Callout.Root> )} <Dialog.Root open={open} onOpenChange={setOpen}> <Dialog.Content size="5"> <Flex direction="column" maxHeight="calc(100vh - var(--dialog-content-padding) * 2 - var(--space-6) - max(var(--space-6), 6vh))" minHeight="400px" > <Flex asChild direction="column" flexGrow="1" minHeight="0"> <form onSubmit={handleSubmit(onSubmit)}> <Dialog.Title>Attribute mapping</Dialog.Title> <Dialog.Description mb="5"> Configure how attributes are mapped between the identity provider (IdP) and your application. Standard attributes such as name and email are required. </Dialog.Description> {Object.keys(errors).length > 0 && ( <Callout.Root color="red" mb="3"> <Callout.Text> Please fill in all required fields before saving. </Callout.Text> </Callout.Root> )} {/* Fixed Header */} <Box style={{ display: 'grid', gridTemplateColumns: '1fr 30px 1fr', gap: 'var(--space-3)', padding: 'var(--space-3)', backgroundColor: 'var(--gray-a2)', border: '1px solid var(--gray-a5)', borderRadius: 'var(--radius-4) var(--radius-4) 0 0', borderBottom: '1px solid var(--gray-a5)', }} > <Text size="2" weight="bold"> Attribute name </Text> <Box /> <Text size="2" weight="bold"> IdP field name </Text> </Box> {/* Scrollable Body */} <Flex asChild direction="column" flexGrow="1" minHeight="0" style={{ border: '1px solid var(--gray-a5)', borderTop: 'none', borderRadius: '0 0 var(--radius-4) var(--radius-4)', }} > <ScrollArea.Root> <ScrollArea.Viewport> <Box> {attributeMappings.map((attr, index) => ( <Box key={attr.key} onClick={(e) => handleRowClick(index, e)} style={{ display: 'grid', gridTemplateColumns: '1fr 30px 1fr', gap: 'var(--space-3)', padding: 'var(--space-3)', alignItems: 'center', borderBottom: index < attributeMappings.length - 1 ? '1px solid var(--gray-a5)' : 'none', minHeight: '44px', cursor: 'pointer', transition: 'background-color 0.1s', ...(errors[attr.key] && { backgroundColor: 'var(--red-a2)', }), }} onMouseEnter={(e) => { if (!errors[attr.key]) { e.currentTarget.style.backgroundColor = 'var(--gray-a2)'; } }} onMouseLeave={(e) => { if (!errors[attr.key]) { e.currentTarget.style.backgroundColor = 'transparent'; } }} > <Flex align="center" gap="2" wrap="wrap"> <Text size="2">{attr.label}</Text> {attr.required && <Badge>Required</Badge>} </Flex> <Flex align="center" justify="center"> <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg" > <path d="M8.14645 3.14645C8.34171 2.95118 8.65829 2.95118 8.85355 3.14645L12.8536 7.14645C13.0488 7.34171 13.0488 7.65829 12.8536 7.85355L8.85355 11.8536C8.65829 12.0488 8.34171 12.0488 8.14645 11.8536C7.95118 11.6583 7.95118 11.3417 8.14645 11.1464L11.2929 8H2.5C2.22386 8 2 7.77614 2 7.5C2 7.22386 2.22386 7 2.5 7H11.2929L8.14645 3.85355C7.95118 3.65829 7.95118 3.34171 8.14645 3.14645Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd" /> </svg> </Flex> <Box ref={(el) => (jsonPathRefs.current[index] = el)} > <Controller control={control} name={attr.key} defaultValue={null} rules={{ validate: (value) => { if (attr.required && !value) { return `${attr.label} is required`; } return true; }, }} render={({ field }) => ( <JsonPathSelector align="end" data={idpAttributeSchema} label={`Source attribute for ${attr.label}`} placeholder="Select source attribute" selectedValue={field.value} onSelectionChange={field.onChange} /> )} /> </Box> </Box> ))} </Box> </ScrollArea.Viewport> <ScrollArea.Scrollbar orientation="vertical" /> </ScrollArea.Root> </Flex> <Flex gap="2" justify="end" mt="5"> <Dialog.Close> <Button type="button">Cancel</Button> </Dialog.Close> <Button color="purple" type="submit"> Save changes </Button> </Flex> </form> </Flex> </Flex> </Dialog.Content> </Dialog.Root> </Flex> ); };