> ## Documentation Index
> Fetch the complete documentation index at: https://anvil.servicetitan.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Edit Card – Design

> Edit Cards group related controls and information together, allowing a user to view and edit the grouped content.

export const LiveCode = ({children, customHeight, clickToLoad, example, fullWidth, fullHeight, hideCodeInLiveCode, screenshot, screenshotOnly, showCode: showCodeProp}) => {
  const SCREENSHOTS_BASE = "https://servicetitan.github.io/anvil2-docs-live-code/screenshots";
  const STACKBLITZ_BASE = "https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples";
  const [showCodeBlock, setShowCodeBlock] = useState(showCodeProp ?? false);
  const [isLocalOverride, setIsLocalOverride] = useState(false);
  useEffect(() => {
    const examplePath = `/images/live-code-screenshots-tmp/${example}.png`;
    fetch(examplePath, {
      method: "HEAD"
    }).then(r => {
      if (r.ok) setIsLocalOverride(true);
    }).catch(() => {});
  }, [example]);
  const screenshotBase = isLocalOverride ? "/images/live-code-screenshots-tmp" : SCREENSHOTS_BASE;
  if (screenshotOnly) {
    return <Frame className="flex flex-col">
        <div className="flex dark:hidden" style={{
      justifyContent: "center",
      alignItems: "center",
      width: fullWidth ? "100%" : "50%",
      minHeight: fullHeight ? "284px" : undefined,
      background: "#FFFFFF"
    }}>
          <img srcset={`${screenshotBase}/${example}.png, ${screenshotBase}/${example}-2x.png 2x`} src={`${screenshotBase}/${example}.png`} alt={example} noZoom />
        </div>
        <div className="hidden dark:flex" style={{
      justifyContent: "center",
      alignItems: "center",
      width: fullWidth ? "100%" : "50%",
      minHeight: fullHeight ? "284px" : undefined,
      background: "#141414"
    }}>
          <img srcset={`${screenshotBase}/${example}-dark.png, ${screenshotBase}/${example}-dark-2x.png 2x`} src={`${screenshotBase}/${example}-dark.png`} alt={example} noZoom />
        </div>
      </Frame>;
  }
  if (screenshot) {
    return <Frame className="flex flex-col -mb-2">
        <div className="flex dark:hidden bg-white dark:bg-codeblock border border-gray-950/10 dark:border-white/10 dark:twoslash-dark rounded-2xl overflow-hidden" style={{
      justifyContent: "center",
      alignItems: "center",
      width: fullWidth ? "100%" : "50%",
      minHeight: fullHeight ? "284px" : undefined
    }}>
          <img srcset={`${screenshotBase}/${example}.png, ${screenshotBase}/${example}-2x.png 2x`} src={`${screenshotBase}/${example}.png`} alt={example} noZoom />
        </div>

        <div className="hidden dark:flex bg-white dark:bg-codeblock border border-gray-950/10 dark:border-white/10 dark:twoslash-dark rounded-2xl overflow-hidden" style={{
      background: "#141414",
      justifyContent: "center",
      alignItems: "center",
      width: fullWidth ? "100%" : "50%",
      minHeight: fullHeight ? "284px" : undefined
    }}>
          <img srcset={`${screenshotBase}/${example}-dark.png, ${screenshotBase}/${example}-dark-2x.png 2x`} src={`${screenshotBase}/${example}-dark.png`} alt={example} noZoom />
        </div>

        <div className="flex justify-end items-center text-xs py-2 px-1 gap-4">
          {!showCodeProp ? <button className="inline-flex justify-end items-center text-gray-700 dark:text-gray-50 hover:text-blue-500 dark:hover:text-blue-300 transition-colors group self-end gap-1 cursor-pointer" onClick={() => setShowCodeBlock(!showCodeBlock)} style={{
      appearance: "none"
    }}>
              <Icon icon="code" size="12px" className="group-hover:bg-blue-500 dark:group-hover:bg-blue-300" />
              <span>{showCodeBlock ? "Hide code" : "Show code"}</span>
            </button> : null}

          <a className="inline-flex justify-end items-center hover:text-blue-500 dark:hover:text-blue-300 transition-colors group self-end gap-1" href={`${STACKBLITZ_BASE}/${example}?file=src/App.tsx`} target="_blank" rel="noreferrer">
            <Icon icon="bolt" size="12px" className="group-hover:bg-blue-500 dark:group-hover:bg-blue-300" />
            <span>StackBlitz demo</span>
          </a>
        </div>

        <div className="grid transition-[grid-template-rows] duration-300 ease-in-out overflow-auto overflow-y-hidden overflow-x-auto" style={showCodeBlock ? {
      gridTemplateRows: "1fr"
    } : {
      gridTemplateRows: "0fr"
    }}>
          <div style={{
      minHeight: 0,
      overflowX: "auto",
      overflowY: "hidden",
      marginBlockStart: "-1.25rem",
      marginBlockEnd: "-1.5rem"
    }}>
            {children}
          </div>
        </div>
      </Frame>;
  } else {
    return <div style={{
      display: "flex",
      width: fullWidth ? "100%" : "50%",
      minHeight: customHeight ? customHeight : "316px",
      resize: "vertical",
      overflow: "auto"
    }}>
        <iframe title={example} style={{
      flex: 1,
      width: fullWidth ? "100%" : "50%",
      minHeight: customHeight ? customHeight : "316px"
    }} src={`${STACKBLITZ_BASE}/${example}?embed=1&hideNavigation=1&hideExplorer=1&terminalHeight=0&file=src/App.tsx${clickToLoad ? "&ctl=1" : ""}${hideCodeInLiveCode ? "&view=preview" : ""}`} allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking" sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts" />
      </div>;
  }
};

export const CodePreviewPlaceholder = ({double, fullWidth}) => {
  const single = <div style={{
    width: fullWidth ? "100%" : "50%",
    borderRadius: "1rem",
    display: "flex",
    padding: "1rem",
    flexDirection: "column",
    gap: "0.5rem",
    height: "10rem",
    marginBlockEnd: "1rem"
  }} className="border-width-default border-color-subdued">
      <div className="bg-strong border-radius-large" style={{
    width: "100%",
    flexGrow: "1"
  }} />
      <div className="bg-strong border-radius-large" style={{
    width: "100%",
    flexGrow: "1"
  }} />
    </div>;
  return double ? <div style={{
    display: "flex",
    gap: "1rem"
  }}>
      {single}
      {single}
    </div> : single;
};

<Frame>
  <div className="w-full h-full bg-[#FFFFFF] p-2 rounded flex items-center justify-center">
    <img noZoom src="https://mintcdn.com/servicetitan/uz2PQSvO75TRhQ38/images/docs/web/components/shared/overview-of-an-edit-card.png?fit=max&auto=format&n=uz2PQSvO75TRhQ38&q=85&s=bea01def576ae7d85380d2ebd3e2ed79" width="986" height="400" data-path="images/docs/web/components/shared/overview-of-an-edit-card.png" />
  </div>
</Frame>

## Anatomy

The Edit Card consists of five primary elements that work together to group related controls and information for viewing and editing.

<Frame>
  <div className="w-full h-full bg-[#FFFFFF] p-2 rounded flex items-center justify-center">
    <img
      src="https://mintcdn.com/servicetitan/CekIMDXcDEhGoN_I/images/docs/web/components/edit-card/design/anatomy-of-an-edit-card.png?fit=max&auto=format&n=CekIMDXcDEhGoN_I&q=85&s=951a899aead047a9f8d11a5d7bc151b4"
      alt="Anatomy of an Edit
Card"
      width="1118"
      height="1310"
      data-path="images/docs/web/components/edit-card/design/anatomy-of-an-edit-card.png"
    />
  </div>
</Frame>

1. Status Icon
2. Title
3. Header action
4. Body content (presented as form actions)
5. Footer actions

## Options

The Edit Card supports multiple status states, action configurations, and flexible body content to accommodate various editing scenarios.

### Status

#### Not Started

<LiveCode example="editcard-state-not-started" screenshot fullWidth>
  ```tsx lines theme={null}
  import { EditCard, Flex } from "@servicetitan/anvil2";

  function App() {
    return (
      <Flex>
        <EditCard
          flexGrow="1"
          headerText="Not Started Header"
          state="not started"
        >
          Body content within the Edit Card.
        </EditCard>
      </Flex>
    );
  }

  export default App;
  ```
</LiveCode>

#### In Progress

The editing state uniquely contains footer actions and no header actions.

<LiveCode example="editcard-state-in-progress" screenshot fullWidth>
  ```tsx lines theme={null}
  import { EditCard, Flex } from "@servicetitan/anvil2";

  function App() {
    return (
      <Flex>
        <EditCard
          flexGrow="1"
          headerText="In Progress Header"
          state="in progress"
        >
          Body content within the Edit Card.
        </EditCard>
      </Flex>
    );
  }

  export default App;
  ```
</LiveCode>

#### Error

<LiveCode example="editcard-state-error" screenshot fullWidth>
  ```tsx lines theme={null}
  import { EditCard, Flex } from "@servicetitan/anvil2";

  function App() {
    return (
      <Flex>
        <EditCard flexGrow="1" headerText="Error Header" state="error">
          Body content within the Edit Card.
        </EditCard>
      </Flex>
    );
  }

  export default App;
  ```
</LiveCode>

#### Complete

<LiveCode example="editcard-state-complete" screenshot fullWidth>
  ```tsx lines theme={null}
  import { EditCard, Flex } from "@servicetitan/anvil2";

  function App() {
    return (
      <Flex>
        <EditCard flexGrow="1" headerText="Complete Header" state="complete">
          Body content within the Edit Card.
        </EditCard>
      </Flex>
    );
  }

  export default App;
  ```
</LiveCode>

### Actions

In the not started, completed, and error statuses, a single action is used in the header region. In the in progress status, a cancel and call-to-action are used in the footer region. The copy is modifiable and each action can be disabled. The actions cannot be modified beyond this.

### Body content

Edit Card supports any type of content below the title. This is typically represented as text in non-editing statuses and as form elements in the in progress status.

<LiveCode example="editcard-children" screenshot fullWidth>
  ```tsx lines theme={null}
  import {
    EditCard,
    Flex,
    Text,
    Grid,
    TextField,
    Radio,
  } from "@servicetitan/anvil2";

  function App() {
    return (
      <Flex direction="column" gap={6}>
        <EditCard headerText="Edit Card" state="not started" />
        <EditCard headerText="Edit Card" state="not started">
          <Flex gap={8} grow={1} wrap="wrap" style={{ width: "500px" }}>
            <Flex direction="column" gap={1} basis="200px" shrink="0" grow="1">
              <Text variant="eyebrow" size="medium">
                Name
              </Text>
              <Text size="medium">Jane Doe</Text>
            </Flex>
            <Flex direction="column" gap={1} basis="200px" shrink="0" grow="1">
              <Text variant="eyebrow" size="medium">
                Notification Frequency
              </Text>
              <Text size="medium">Weekly</Text>
            </Flex>
          </Flex>
        </EditCard>
        <EditCard headerText="Edit Card" state="in progress">
          <Grid gap="6">
            <TextField label="Name" />
            <Radio.Group legend="Notification Frequency">
              <Radio name="ex1" value="daily" label="Daily" />
              <Radio name="ex1" value="weekly" label="Weekly" defaultChecked />
              <Radio name="ex1" value="monthly" label="Monthly" />
            </Radio.Group>
          </Grid>
        </EditCard>
      </Flex>
    );
  }

  export default App;
  ```
</LiveCode>

## Behavior

The Edit Card responds to state changes and content overflow while maintaining consistent editing interactions.

### Overflow handling

<LiveCode example="editcard-overflow" screenshot fullWidth>
  ```tsx lines theme={null}
  import { EditCard } from "@servicetitan/anvil2";

  function App() {
    return (
      <EditCard
        style={{ maxWidth: 325 }}
        flexGrow="1"
        headerText="An Edit Card title will wrap when not enough space is available for it."
        state="not started"
      >
        Content inside the Edit Card will overflow depending on how it is
        implemented, with the most typical being wrapping.
      </EditCard>
    );
  }

  export default App;
  ```
</LiveCode>

## Usage Guidelines

### When to Use

Use an Edit Card when a set of actions and information should be chunked in the UI, while also needing the ability to toggle an editing state.

Edit Cards serve as a form of progressive disclosure, showing a summary of configurations without also showing all the underlying UI controls.

### When not to use

Edit Cards create friction to accessing controls. Avoid using an excess number of them.

When using Page-level save controls, Edit Cards cause confusion and unnecessary friction. In these cases, group UI controls another way.

### Alternatives

#### Edit Card vs Stepper

Steppers move through a linear progression of steps. Edit Cards support both linear and unrelated blocks of content.

Steppers are typically scoped to the whole view, e.g. a fullscreen Dialog, while Edit Card can be scoped within smaller sections of a view, e.g. part of a Drawer screen.

Steppers only give summaries of the flow indirectly, typically on the final step of a flow, while Edit Card shows a summary in most of its states.

Steppers and Edit Cards work together for complex flows.

### How to Use

#### Summarize user selections

The incomplete, success, and error state of Edit Cards show a summary of the user's configuration in its body section. Customize this to fit the context of the Edit Card controls.

<LiveCode example="editcard-summary-do" screenshot fullWidth>
  ```tsx lines theme={null}
  import {
    EditCard,
    Grid,
    TextField,
    Radio,
    Flex,
    Text,
    type EditCardState,
    type EditCardChange,
    type RadioState,
  } from "@servicetitan/anvil2";
  import { useState, type ChangeEvent } from "react";

  function App() {
    const [state, setState] = useState<EditCardState>("complete");
    const onStateChange = (change: EditCardChange) => {
      if (change === "edit") {
        console.log("in progress");
        setState("in progress");
      }
      if (change === "save") {
        console.log("saved");
        setState("complete");
      }

      if (change === "cancel") {
        setState("complete");
      }
    };

    const [inputValue, setInputValue] = useState("Jane Doe");

    const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
      setInputValue(event.target.value);
    };

    const [list, setList] = useState([
      {
        label: "Email",
        checked: false,
      },
      {
        label: "Text",
        checked: true,
      },
    ]);

    const selectedPref = list.find((item) => item.checked);
    console.info("selectedPref:", selectedPref);

    const handleUpdate = (_state: RadioState | undefined, index: number) => {
      setList((prev) =>
        prev.map((item, i) => ({
          label: item.label,
          checked: index === i,
        })),
      );
    };

    const active = (
      <Grid gap="6">
        <TextField label="Name" value={inputValue} onChange={handleInputChange} />

        <Radio.Group legend="Preferred communication method">
          {list.map((item, i) => {
            return (
              <Radio
                key={i}
                checked={item.checked}
                label={item.label}
                onChange={(
                  _e?: ChangeEvent<HTMLInputElement>,
                  state?: RadioState,
                ) => handleUpdate(state, i)}
              />
            );
          })}
        </Radio.Group>
      </Grid>
    );

    const summary = (
      <Flex gap={8} grow={1} wrap="wrap">
        <Flex direction="column" gap={1} basis="200px">
          <Text variant="eyebrow" size="medium">
            Name
          </Text>
          <Text size="medium">{inputValue}</Text>
        </Flex>
        <Flex direction="column" gap={1} basis="200px">
          <Text variant="eyebrow" size="medium">
            Communication Method
          </Text>
          <Text size="medium">{selectedPref?.label}</Text>
        </Flex>
      </Flex>
    );

    return (
      <EditCard
        state={state}
        headerText="User Information"
        onStateChange={onStateChange}
      >
        <Flex grow={1} style={{ width: "500px", maxWidth: "100%" }}>
          {(state === "not started" || state === "complete") && summary}
          {state === "in progress" && active}
        </Flex>
      </EditCard>
    );
  }

  export default App;
  ```
</LiveCode>

#### Number of controls in the Edit Card

No specific rule exists for how many controls exist in Edit Card.

* Avoid having only one control in Edit Card. This creates excessive friction in the UI.
* Avoid having excessively long Edit Cards. Tall editing states push the Save controls out of view. Break up the content into multiple Edit Cards, or omit Edit Card in favor of a page-level flow.

#### Unordered set of Edit Cards

Edit Cards work together without being dependent on other Edit Cards.

* Multiple Edit Cards can be in the editing state at once
* Avoid disabling any edit or save actions across Edit Cards

#### Ordered set of Edit Cards

Edit Cards work together to create a sequential flow, where each step builds on top of each other. Edit Cards do not provide any additional interactions for this scenario.

* When a user clicks on the Next/Save action within one Edit Card, have the subsequent card automatically open.
* Disable any edit actions on Edit Cards that aren't ready to be interacted with.
* Factor in downstream effects, e.g. when a user returns to an early step and modifies a setting that impacts already completed steps.

#### Saving Edit Cards

* When in the editing state, the footer action has either an explicit Save or Next/Finish.
* When Edit Card has an explicit Save action, the system remembers this save in some way.
* If Edit Card uses Next/Finish instead of Save, changed content doesn't get preserved until another save occurs.

##### Guidance for Edit Cards and Page-level saves

* In general, avoid having both Edit Card and page-level save at once.
* If there's both an Edit Card save and page-level save, saving Edit Card but not saving the page-level save produces a draft state that preserves Edit Card's new content.
  * If this cannot be done, use the Next/Finish copy, or make major UI revisions to avoid the scenario entirely.
* Edit Card and page-level saves work simultaneously. In usability testing multiple save scenarios, users correctly utilized the contextually relevant save every time.

#### Recommended spacing between Edit Cards

Use a 24px / 1.5rem / size-6 gap between stacked Edit Cards.

<LiveCode example="editcard-spacing" screenshot fullWidth>
  ```tsx lines theme={null}
  import { EditCard, Flex } from "@servicetitan/anvil2";

  function App() {
    return (
      <Flex direction="column" gap={6}>
        <EditCard headerText="Edit Card" state="not started" />
        <EditCard headerText="Edit Card" state="not started" />
        <EditCard headerText="Edit Card" state="not started" />
      </Flex>
    );
  }

  export default App;
  ```
</LiveCode>

## Content

Content within the Edit Card should clearly communicate the current state and provide appropriate summaries or editing controls.

## Keyboard Interaction

Users can navigate the Edit Card using standard keyboard controls.

### Accessibility

#### Suggested focus management

Consider these factors when handling Edit Card's focus behavior between state changes.

* When entering the 'In progress' state, focus goes to the first focusable element in its body content.
* When leaving the 'In progress' state, focus placement varies:
  * If Edit Cards are not part of an ordered flow, return focus to the element that triggered the state originally, if still available. If not, place it on the next tabbable element.
  * If Edit Card is part of a set of ordered Edit Cards, focus goes to the next focusable element within the next Edit Card, assuming it is not the last Edit Card. If it's the last Edit Card in the flow, bring focus to the most logical place.

For more guidance on focus management with dynamic content, see [changing content best practices](/docs/accessibility/changing-content).
