> ## 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.

# AppShell – Design

> App shells frame the overall layout for Carto product experiences.

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;
};

<LiveCode example="carto-app-shell" screenshot fullWidth>
  ```tsx lines theme={null}
  import { AppShell, Text } from "@servicetitan/carto-react-kit";

  function App() {
    return (
      <div style={{ width: "70rem", height: 400 }}>
        <AppShell
          chat={
            <div
              style={{
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                height: "100%",
                padding: 24,
                backgroundColor: "#eeeeee",
              }}
            >
              <Text text="Chat region" />
            </div>
          }
        />
      </div>
    );
  }

  export default App;
  ```
</LiveCode>

## Options

AppShell composes up to four regions — a full-width header, a left rail, a required center chat, and a right artifact panel. All region state (visibility, width, presentation) lives inside the shell; drive it imperatively through `useAppSurface()` or a `controlRef`, never through props that re-render the slots.

### Regions

<LiveCode example="carto-app-shell-regions" screenshot fullWidth>
  ```tsx lines theme={null}
  import {
    AppHeader,
    AppShell,
    CartoTheme,
    Text,
    useAppSurface,
  } from "@servicetitan/carto-react-kit";
  import {
    IconPanelLeft,
    IconPanelRight,
  } from "@servicetitan/carto-react-kit/icons";
  import "@servicetitan/carto-react-kit/styles.css";

  function ShellHeader() {
    const { toggleRail, toggleArtifact } = useAppSurface();
    return (
      <AppHeader
        title="Title of conversation"
        leadingActions={[
          {
            id: "rail",
            icon: <IconPanelLeft />,
            label: "Toggle rail",
            onPress: toggleRail,
          },
        ]}
        trailingActions={[
          {
            id: "artifact",
            icon: <IconPanelRight />,
            label: "Toggle artifact panel",
            onPress: toggleArtifact,
          },
        ]}
      />
    );
  }

  function RegionPlaceholder({
    label,
    background,
  }: {
    label: string;
    background: string;
  }) {
    return (
      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          height: "100%",
          padding: 24,
          backgroundColor: background,
        }}
      >
        <Text text={label} />
      </div>
    );
  }

  function App() {
    return (
      <CartoTheme>
        <div style={{ width: "70rem", height: 400 }}>
          <AppShell
            header={<ShellHeader />}
            rail={
              <RegionPlaceholder label="Rail region" background={"#dce4ea"} />
            }
            chat={
              <RegionPlaceholder label="Chat region" background={"#eeeeee"} />
            }
            artifact={
              <RegionPlaceholder label="Artifact region" background={"#9ecaff"} />
            }
          />
        </div>
      </CartoTheme>
    );
  }

  export default App;
  ```
</LiveCode>

#### Artifact

The right-side region — typically a canvas or generated-artifact panel. Omit it to render no artifact. It's drag-resizable from its leading edge and yields to a modal drawer, after the rail, once the shell can no longer fit it inline.

#### Chat

The required, invariant center. Chat is always inline and never collapses — it's the one region guaranteed to remain, and it flexes to absorb whatever width the side regions yield.

#### Header

The full-width region across the top, typically an `AppHeader`. Omit it for a shell with no header.

#### Left Rail

The left region — typically navigation. It's fixed-width (240px) and never drag-resized; when it no longer fits, it yields to a drawer before the artifact does.

### Artifact Width

<LiveCode example="carto-app-shell-artifact-width" screenshot fullWidth>
  ```tsx lines theme={null}
  import {
    AppHeader,
    AppShell,
    CartoTheme,
    Text,
    useAppSurface,
  } from "@servicetitan/carto-react-kit";
  import {
    IconPanelLeft,
    IconPanelRight,
  } from "@servicetitan/carto-react-kit/icons";
  import "@servicetitan/carto-react-kit/styles.css";

  function LargeArtifactHeader() {
    const { toggleRail, toggleArtifact } = useAppSurface();
    return (
      <AppHeader
        title="Title of conversation"
        leadingActions={[
          {
            id: "rail",
            icon: <IconPanelLeft />,
            label: "Toggle rail",
            onPress: toggleRail,
          },
        ]}
        trailingActions={[
          {
            id: "artifact",
            icon: <IconPanelRight />,
            label: "Toggle artifact panel (large)",
            onPress: () => toggleArtifact({ width: "large" }),
          },
        ]}
      />
    );
  }

  function RegionPlaceholder({
    label,
    background,
  }: {
    label: string;
    background: string;
  }) {
    return (
      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          height: "100%",
          padding: 24,
          backgroundColor: background,
        }}
      >
        <Text text={label} />
      </div>
    );
  }

  function App() {
    return (
      <CartoTheme>
        <div style={{ width: "70rem", height: 400 }}>
          <AppShell
            header={<LargeArtifactHeader />}
            rail={
              <RegionPlaceholder label="Rail region" background={"#dce4ea"} />
            }
            chat={
              <RegionPlaceholder label="Chat region" background={"#eeeeee"} />
            }
            artifact={
              <RegionPlaceholder label="Artifact region" background={"#9ecaff"} />
            }
            defaultArtifactWidth="large"
          />
        </div>
      </CartoTheme>
    );
  }

  export default App;
  ```
</LiveCode>

`defaultArtifactWidth` sets the starting preset — `"regular"`, a fixed 560px (the default, shown in Regions above), or `"large"`, a 720px floor that fills the available width and collapses the rail to reclaim its space. Either preset is a starting point only; the artifact stays drag-resizable from there, and the same presets are available imperatively via `showArtifact({ width })` / `toggleArtifact({ width })`.

## Behavior

Region presentation responds purely to available width — there are no viewport breakpoints — and every region can be shown or hidden imperatively regardless of how it got there.

### Artifact Resize

<LiveCode example="carto-app-shell-artifact-resize" screenshot fullWidth>
  ```tsx lines theme={null}
  import {
    AppHeader,
    AppShell,
    CartoTheme,
    Text,
    useAppSurface,
  } from "@servicetitan/carto-react-kit";
  import {
    IconPanelLeft,
    IconPanelRight,
  } from "@servicetitan/carto-react-kit/icons";
  import "@servicetitan/carto-react-kit/styles.css";

  function ShellHeader() {
    const { toggleRail, toggleArtifact } = useAppSurface();
    return (
      <AppHeader
        title="Title of conversation"
        leadingActions={[
          {
            id: "rail",
            icon: <IconPanelLeft />,
            label: "Toggle rail",
            onPress: toggleRail,
          },
        ]}
        trailingActions={[
          {
            id: "artifact",
            icon: <IconPanelRight />,
            label: "Toggle artifact panel",
            onPress: toggleArtifact,
          },
        ]}
      />
    );
  }

  function RegionPlaceholder({
    label,
    background,
  }: {
    label: string;
    background: string;
  }) {
    return (
      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          height: "100%",
          padding: 24,
          backgroundColor: background,
        }}
      >
        <Text text={label} />
      </div>
    );
  }

  function App() {
    return (
      <CartoTheme>
        <div style={{ width: "76rem", height: 400 }}>
          <AppShell
            header={<ShellHeader />}
            chat={
              <RegionPlaceholder label="Chat region" background={"#eeeeee"} />
            }
            artifact={
              <RegionPlaceholder label="Artifact region" background={"#9ecaff"} />
            }
          />
        </div>
      </CartoTheme>
    );
  }

  export default App;
  ```
</LiveCode>

Drag the artifact's leading edge (or focus its handle and use the arrow keys) to resize it. Dragging past the point where chat would drop below its minimum width detaches the artifact into a drawer that keeps widening up to the full viewport; dragging back re-attaches it inline. Chat never goes below its minimum — the artifact and rail absorb the constraint instead.

### Auto-collapse

<LiveCode example="carto-app-shell-auto-collapse" screenshot fullWidth>
  ```tsx lines theme={null}
  import {
    AppHeader,
    AppShell,
    CartoTheme,
    Text,
    useAppSurface,
  } from "@servicetitan/carto-react-kit";
  import {
    IconPanelLeft,
    IconPanelRight,
  } from "@servicetitan/carto-react-kit/icons";
  import "@servicetitan/carto-react-kit/styles.css";

  function ShellHeader() {
    const { toggleRail, toggleArtifact } = useAppSurface();
    return (
      <AppHeader
        title="Title of conversation"
        leadingActions={[
          {
            id: "rail",
            icon: <IconPanelLeft />,
            label: "Toggle rail",
            onPress: toggleRail,
          },
        ]}
        trailingActions={[
          {
            id: "artifact",
            icon: <IconPanelRight />,
            label: "Toggle artifact panel",
            onPress: toggleArtifact,
          },
        ]}
      />
    );
  }

  function RegionPlaceholder({
    label,
    background,
  }: {
    label: string;
    background: string;
  }) {
    return (
      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          height: "100%",
          padding: 24,
          backgroundColor: background,
        }}
      >
        <Text text={label} />
      </div>
    );
  }

  function App() {
    return (
      <CartoTheme>
        <div style={{ width: 760, height: 400, border: "1px solid #ccc" }}>
          <AppShell
            header={<ShellHeader />}
            rail={
              <RegionPlaceholder label="Rail region" background={"#dce4ea"} />
            }
            chat={
              <RegionPlaceholder label="Chat region" background={"#eeeeee"} />
            }
            artifact={
              <RegionPlaceholder label="Artifact region" background={"#9ecaff"} />
            }
          />
        </div>
      </CartoTheme>
    );
  }

  export default App;
  ```
</LiveCode>

When a side region no longer fits inline, it becomes a modal drawer rather than disappearing — reachable again via the header's toggle. The rail collapses first (lowest priority), then the artifact, purely based on each region's own minimum width against the space available. Widening the shell re-inlines a region the instant it fits again.

### Initial Visibility

<LiveCode example="carto-app-shell-initial-visibility" screenshot fullWidth>
  ```tsx lines theme={null}
  import { useEffect, useRef } from "react";
  import {
    AppHeader,
    AppShell,
    CartoTheme,
    Text,
    useAppSurface,
    type AppSurfaceHandle,
  } from "@servicetitan/carto-react-kit";
  import {
    IconPanelLeft,
    IconPanelRight,
  } from "@servicetitan/carto-react-kit/icons";
  import "@servicetitan/carto-react-kit/styles.css";

  function ShellHeader() {
    const { toggleRail, toggleArtifact } = useAppSurface();
    return (
      <AppHeader
        title="Title of conversation"
        leadingActions={[
          {
            id: "rail",
            icon: <IconPanelLeft />,
            label: "Toggle rail",
            onPress: toggleRail,
          },
        ]}
        trailingActions={[
          {
            id: "artifact",
            icon: <IconPanelRight />,
            label: "Toggle artifact panel",
            onPress: toggleArtifact,
          },
        ]}
      />
    );
  }

  function RegionPlaceholder({
    label,
    background,
  }: {
    label: string;
    background: string;
  }) {
    return (
      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          height: "100%",
          padding: 24,
          backgroundColor: background,
        }}
      >
        <Text text={label} />
      </div>
    );
  }

  function App() {
    const controlRef = useRef<AppSurfaceHandle>(null);
    useEffect(() => controlRef.current?.hideRail(), []);

    return (
      <CartoTheme>
        <div style={{ width: "70rem", height: 400 }}>
          <AppShell
            controlRef={controlRef}
            header={<ShellHeader />}
            rail={
              <RegionPlaceholder label="Rail region" background={"#dce4ea"} />
            }
            chat={
              <RegionPlaceholder label="Chat region" background={"#eeeeee"} />
            }
            artifact={
              <RegionPlaceholder label="Artifact region" background={"#9ecaff"} />
            }
          />
        </div>
      </CartoTheme>
    );
  }

  export default App;
  ```
</LiveCode>

Region state is internal, so initial visibility is set imperatively rather than through a prop: grab a `controlRef` and call `hideRail()` (or `hideArtifact()`) in an effect on mount. Its space is reclaimed by chat until the header's toggle brings it back.

## Keyboard Interaction

Users can navigate, resize, and dismiss AppShell's regions using standard keyboard controls.

| Key                      | Description                                                          |
| ------------------------ | -------------------------------------------------------------------- |
| Tab                      | Moves focus among header actions, resize handles, and region content |
| Arrow Left / Arrow Right | Resizes the focused handle by one step                               |
| Escape                   | Dismisses an open drawer                                             |
| Enter / Space            | Activates the focused header action                                  |

## Patterns that use this component

* [Agentic Experience Shell](/docs/kits/ai-kit/patterns/agentic-experience-shell) — Arranges the header, history rail, conversation, and supporting panel
