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

# BottomSheet – Design

> Bottom sheets present secondary content in a panel that rises from the bottom of the screen.

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-bottom-sheet-full-page" screenshot fullWidth>
  ```tsx lines theme={null}
  import { BottomSheet, Button } from "@servicetitan/carto-react-kit";

  function App() {
    return (
      <BottomSheet
        title="Quick actions"
        content="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\nSed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt."
        defaultOpen
        trigger={<Button label="Open sheet" variant="secondary" />}
      />
    );
  }

  export default App;
  ```
</LiveCode>

## Options

The Bottom Sheet supports an optional subheader, an optional close button, and any content composed from Carto components.

### Subheader

<LiveCode example="carto-bottom-sheet-subheader-full-page" screenshot fullWidth>
  ```tsx lines theme={null}
  import { BottomSheet, Button } from "@servicetitan/carto-react-kit";

  function App() {
    return (
      <BottomSheet
        title="Job #48213"
        subheader="Scheduled for tomorrow at 9:00 AM"
        content="Review the job details before confirming the appointment."
        defaultOpen
        trigger={<Button label="Open sheet" variant="secondary" />}
      />
    );
  }

  export default App;
  ```
</LiveCode>

An optional subheader renders a secondary line beneath the title — useful for a short status or timestamp. Omit it for a title-only header, as in the hero example above.

### Close Button

<LiveCode example="carto-bottom-sheet-close-button-full-page" screenshot fullWidth>
  ```tsx lines theme={null}
  import { BottomSheet, Button } from "@servicetitan/carto-react-kit";

  function App() {
    return (
      <BottomSheet
        title="Quick actions"
        content="Select an action to continue."
        showCloseButton={false}
        defaultOpen
        trigger={<Button label="Open sheet" variant="secondary" />}
      />
    );
  }

  export default App;
  ```
</LiveCode>

The header close button is shown by default. Set `showCloseButton={false}` to hide it — the sheet remains dismissable via the drag handle, the backdrop, and Escape.

### Content

<LiveCode example="carto-bottom-sheet-content-full-page" screenshot fullWidth>
  ```tsx lines theme={null}
  import { BottomSheet, Button, Checkbox } from "@servicetitan/carto-react-kit";

  function App() {
    return (
      <BottomSheet
        title="Filter jobs"
        content={
          <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
            <Checkbox label="Show completed jobs" defaultSelected />
            <Checkbox label="Show cancelled jobs" />
            <Checkbox label="Only jobs scheduled today" />
          </div>
        }
        defaultOpen
        trigger={<Button label="Open sheet" variant="secondary" />}
      />
    );
  }

  export default App;
  ```
</LiveCode>

`content` accepts any `ReactNode`, so compose Carto components — `Checkbox`, `ArtifactCard`, `ActionLog`, and so on — to build the sheet's body.

## Behavior

The Bottom Sheet negotiates its own height from content and viewport, and its body scrolls independently of the pinned header.

### Sizing and Resizing

The sheet has a single, self-negotiated size rather than discrete size options. It opens at a start height that hugs its content up to 50% of the viewport, and the drag handle — draggable by pointer, or by Arrow Up / Arrow Down / Home / End when focused — resizes it between that start height and a 90% viewport ceiling. Dragging or pressing Arrow Down below the start height closes the sheet.

### Scrolling Body

<LiveCode example="carto-bottom-sheet-scrolling-body-full-page" screenshot fullWidth>
  ```tsx lines theme={null}
  import { BottomSheet, Button, Text } from "@servicetitan/carto-react-kit";

  function App() {
    return (
      <BottomSheet
        title="Terms of service"
        content={
          <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
            {Array.from({ length: 12 }, (_, i) => (
              <Text
                key={i}
                text={`Section ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`}
              />
            ))}
          </div>
        }
        defaultOpen
        trigger={<Button label="Open sheet" variant="secondary" />}
      />
    );
  }

  export default App;
  ```
</LiveCode>

When content exceeds the current detent, the body scrolls independently while the header and drag handle stay pinned. A scroll shadow appears once the body has scrolled.

## Keyboard Interaction

Users can navigate, resize, and dismiss the Bottom Sheet using standard keyboard controls.

| Key                   | Description                                                       |
| --------------------- | ----------------------------------------------------------------- |
| Tab                   | Moves focus among the drag handle, close button, and body content |
| Arrow Up / Arrow Down | Resizes the sheet by one detent when the drag handle is focused   |
| Home / End            | Jumps the drag handle to the start or maximum detent              |
| Escape                | Dismisses the sheet                                               |
