Customizing array of objects UI with popover and descriptive cards

6 replies
Last updated: May 20, 2024
Hey everyone!
I have just a simple array of objects the user can input in this field. I would like to customise the UI so that instead of the basic stacked list i would get something like a popover window with more descriptive cards for each possible item.

I really don't know where to start and I the documentation is not helping at all.
AI Update

You're looking to customize the UI for an array of objects field in Sanity Studio! Instead of the default stacked list, you want a more visual interface with descriptive cards or a popover-style presentation. Here's how to approach this:

Control the Editing Modal

The simplest way to change how array items are edited is through the modal option in your array schema:

defineField({
  name: 'yourArrayField',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'yourObjectType'
    })
  ],
  options: {
    modal: {
      type: 'popover', // or 'dialog'
      width: 'auto' // or a specific number like 800
    }
  }
})

The type can be either 'popover' (for a smaller overlay) or 'dialog' (for a full modal). The default is {type: 'dialog', width: 'auto'}.

Customize Array Item Previews

To show more descriptive cards for each item, customize the preview for your object type:

defineType({
  name: 'yourObjectType',
  type: 'object',
  fields: [
    // your fields...
  ],
  preview: {
    select: {
      title: 'fieldName',
      subtitle: 'anotherField',
      media: 'imageField'
    },
    prepare({title, subtitle, media}) {
      return {
        title,
        subtitle,
        media
      }
    }
  }
})

Check out this guide on creating richer array item previews for more details.

Full Custom Input Component

For complete control over the array UI, you can create a custom input component using the Form Components API:

import {ArrayOfObjectsInputProps} from 'sanity'

export function CustomArrayInput(props: ArrayOfObjectsInputProps) {
  const {renderDefault, members, value} = props
  
  // You can customize the presentation here
  // Access members to get the array items
  // Build your own card-based UI
  
  return renderDefault(props) // or your custom JSX
}

Then add it to your schema:

components: {
  input: CustomArrayInput
}

The renderDefault function is particularly useful because it lets you wrap or enhance the existing UI while preserving Sanity's real-time collaboration features.

For step-by-step examples of building custom array inputs, check out:

Start with the modal option for quick wins, then move to custom components if you need more control!

Show original thread
6 replies
Resolved this problem. There was a nice useful snippet on some forum i was able to adopt.
You should post your solution! I’m sure it would help others
Gladly!
This is the custom component that will be attached to the array field. I didnt have time to style it much, but basically we are passing in all props and they are available in here for displaying anything. I also noticed it can be used to prepopulate fields is that is interesting.
import { ArrayOfObjectsInputProps, BooleanSchemaType, FileSchemaType, NumberSchemaType, ObjectSchemaType, ReferenceSchemaType, StringSchemaType } from "sanity";
import { Grid, Stack, Button, Dialog, Box, Card, Heading } from "@sanity/ui";
import { useCallback, useState } from "react";
import { AddIcon } from "@sanity/icons";
import { randomKey } from "@sanity/util/content";
import React from "react";

type Schema = BooleanSchemaType | FileSchemaType | NumberSchemaType | ObjectSchemaType | StringSchemaType | ReferenceSchemaType;

const PageBuilderInput = (props: ArrayOfObjectsInputProps) => {
  const { onInsert } = props;
  const [open, setOpen] = useState(false);
  const onClose = useCallback(() => setOpen(false), []);
  const onOpen = useCallback(() => setOpen(true), []);

  const onSelectItem = useCallback((schema: Schema) => {
    const key = randomKey(12);
    onInsert({
      items: [
        {
          _type: schema.name,
          _key: key,
        } as any,
      ],
      position: "after",
      referenceItem: -1,
      open: true,
    });
    onClose();
  }, [onInsert, onClose]);

  return (
    <>
      <Stack space={3}>
        {props.renderDefault({
          ...props,
          arrayFunctions: () => {
            return (
              <Button
                onClick={onOpen}
                icon={AddIcon}
                mode="ghost"
                text="Add item"
              />
            );
          },
        })}
      </Stack>

      {open && (
        <Dialog
          header="Select a section"
          id="dialog-example"
          width={4}
          onClose={onClose}
          zOffset={1000}
        >
          <Box padding={1}>
            <Grid autoCols={'auto'} columns={[1, 2, 2, 3, 4]} autoFlow={'row dense'} gap={[3]} padding={4}>
              {props.schemaType.of.map((schema, index) => {
                return (
                  <PreviewCard
                    key={index}
                    schema={schema}
                    onClick={() => onSelectItem(schema)}
                  />
                );
              })}
            </Grid>
          </Box>
        </Dialog>
      )}
    </>
  );
};

type PreviewProps = {
  onClick: React.MouseEventHandler<HTMLDivElement> | undefined,
  schema: Schema
}

function PreviewCard(props: PreviewProps) {
  const { onClick, schema } = props;
  const Icon = schema.icon || (() => <span />); // Default to empty span if no icon

  return (
    <Card
      role="button"
      padding={2}
      onClick={onClick}
      style={{
        cursor: "pointer",
        textAlign: "center",
        borderRadius: "10px",
        border: "1px solid #f0f0f0",
        backgroundColor: "#fff",
        boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)"
      }}
    >
      <Stack padding={2} space={[3]}>
        <div style={{ justifyContent: "center", alignItems: "center" }}>
          <div style={{ fontSize: "2rem", color: "#333" }}>
            <Icon />
          </div>
          <Heading as="h5" size={1} style={{ marginTop: "1rem", color: "#333" }}>
            {schema.title}
          </Heading>
          <p style={{ color: "#777", fontSize: "0.875rem", marginTop: "0.5rem" }}>
            {schema.description}
          </p>
        </div>
      </Stack>
    </Card>
  );
}

export default PageBuilderInput;
A simple example of a usecase bellow:

 defineField({
          name: 'content',
          title: 'Content',
          description: 'Content of the page. Build your page by adding blocks here.',
          validation: (Rule) => Rule.required().error('A page without content is not really a page...'),
          type: 'array',
          group: 'page',
          of: [
              defineArrayMember({
                type: "heroBlock",
                icon: DropIcon,
                description: 'A hero section with a title, subtitle, image and buttons',
              }),
              defineArrayMember({
                type: "textBlock",
                icon: SunIcon,
                description: 'A hero section with a title, subtitle, image and buttons',
              }),
              defineArrayMember({
                type: "ctaBlock",
                icon: SunIcon,
                description: 'A hero section with a title, subtitle, image and buttons',
              }),
              defineArrayMember({
                type: "imageCarousel",
                icon: SunIcon,
                description: 'A hero section with a title, subtitle, image and buttons',
              }),
              defineArrayMember({
                type: 'heroSection', 
                icon: SunIcon,
                description: 'A hero section with a title, subtitle, image and buttons',
              }),
          ],
          components: {
            input: PageBuilderInput,
          }
      }),
It will look something like this:
I've created a pretty nice custom array input component borrowing snippets from here and there.
Updating here for those that are interested.
user T

I had more time to play around with the custom input component. I added some improved styling and instead of icons I eventually decided it would be better to showcase an example image of the component to improve the user experience. I also added a search feature which is great after the number of possible content blocks grows. This new component is also adjusted to better respond to different screen sizes.


import {
  ArrayOfObjectsInputProps,
  BooleanSchemaType,
  FileSchemaType,
  NumberSchemaType,
  ObjectSchemaType,
  ReferenceSchemaType,
  StringSchemaType
} from "sanity";
import {
  Grid,
  Stack,
  Button,
  Dialog,
  Box,
  Card,
  Heading,
  Flex,
  Text,
  TextInput
} from "@sanity/ui";
import { useCallback, useState } from "react";
import { AddIcon, SearchIcon } from "@sanity/icons";
import { randomKey } from "@sanity/util/content";
import React from "react";

type Schema = BooleanSchemaType | FileSchemaType | NumberSchemaType | ObjectSchemaType | StringSchemaType | ReferenceSchemaType;

const PageBuilderInput = (props: ArrayOfObjectsInputProps) => {
  const { onInsert } = props;
  const [open, setOpen] = useState(false);
  const [searchValue, setSearchValue] = useState('');
  const onClose = useCallback(() => setOpen(false), []);
  const onOpen = useCallback(() => setOpen(true), []);

  const onSelectItem = useCallback((schema: Schema) => {
    const key = randomKey(12);
    onInsert({
      items: [
        {
          _type: schema.name,
          _key: key,
        } as any,
      ],
      position: "after",
      referenceItem: -1,
      open: true,
    });
    onClose();
  }, [onInsert, onClose]);

  const filteredSchemas = props.schemaType.of.filter((schema) => {
    const searchValueLower = searchValue.toLowerCase();
    return (
      schema.title?.toLowerCase().includes(searchValueLower) ||
      schema.description?.toLowerCase().includes(searchValueLower)
    );
  });

  return (
    <>
      <Stack space={3}>
        {props.renderDefault({
          ...props,
          arrayFunctions: () => {
            return (
              <Button
                onClick={onOpen}
                icon={AddIcon}
                mode="ghost"
                text="Add item"
              />
            );
          },
        })}
      </Stack>

      {open && (
        <Dialog
          header="Select a section"
          id="dialog-example"
          width={800}
          onClose={onClose}
          zOffset={1000}
        >
          <Box padding={4} style={{ borderBottom: '1px solid var(--card-border-color)' }}>
            <TextInput
              fontSize={[2]}
              onChange={(event) => setSearchValue(event.currentTarget.value)}
              padding={[3, 3, 4]}
              radius={2}
              placeholder="Search"
              value={searchValue}
              autoFocus={true}
              icon={SearchIcon}
            />
          </Box>
          <Grid columns={[1, 1, 1, 2, 3]} gap={4} padding={4}>
            {filteredSchemas.map((schema, index) => (
              <PreviewCard
                key={index}
                schema={schema}
                onClick={() => onSelectItem(schema)}
              />
            ))}
          </Grid>
        </Dialog>
      )}
    </>
  );
};

type PreviewProps = {
  onClick: React.MouseEventHandler<HTMLDivElement> | undefined,
  schema: Schema
}

function PreviewCard(props: PreviewProps) {
  const { onClick, schema } = props;
  const [isHovered, setIsHovered] = useState(false);

  return (
    <Card
      padding={[3, 3, 4]}
      radius={2}
      shadow={isHovered ? 2 : 1}
      onClick={onClick}
      style={{ cursor: 'pointer' }}
      tone="default"
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      <Flex gap={4} direction="column">
        <Card
          radius={2}
          shadow={1}
          style={{
            position: 'relative',
            aspectRatio: '2 / 1',
            backgroundColor: 'var(--card-skeleton-color-from)',
            backgroundImage: `url(/images/${schema.name}.png)`,
            backgroundSize: '100% auto',
            backgroundPosition: 'center center',
            backgroundRepeat: 'no-repeat',
          }}
        />

        <Flex gap={3} direction="column">
          <Text size={[2]} weight="medium">
            {schema.title}
          </Text>

          <Text size={1} muted>
            {schema.description ? schema.description : 'No description'}
          </Text>
        </Flex>
      </Flex>
    </Card>
  );
}

export default PageBuilderInput;
In this version the images come form a public/images folder setup at the root of the project and are fetched using the schema name. so as long as the naming for the files and the naming for the components is identical it works like a charm.


 defineField({
          name: 'content',
          title: 'Content',
          description: 'Content of the page. Build your page by adding blocks here.',
          validation: (Rule) => Rule.required().error('A page without content is not really a page...'),
          type: 'array',
          group: 'page',
          of: [
              defineArrayMember({
                type: "textBlock",
                description: 'Rich text content. Ideal for adding paragraphs of text.',
              }),
              defineArrayMember({
                type: "imageCarousel",
                description: 'Content: a list of images. Ideal for showcasing a collection of images.',
              }),
              defineArrayMember({
                type: 'heroSection', 
                description: 'Content:title, description, image and buttons. Ideal for presenting general information about a single entity.',
              }),
          ],
          components: {
            input: PageBuilderInput,
          }
      }),
This is something i've been meaning to do for a long time and i'm sure others will also have some use for this component.
Thanks for sharing!

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?