airtable-custom-interface-extensions
Complete SDK reference for building Airtable Interface Extensions — models, hooks, components, field types, write patterns, and common pitfalls.
Source facts
- Repository
- victoriaplummer/airtable-interface-extension-toolkit
- Last source activity
- March 27, 2026 at 15:15
- Detected SKILL.md language
- English
- Stars
- 51
- Forks
- 2
Install options
The review-first prompt is selected by default. You can switch to a direct command or download a local copy.
Review the source files
Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.
Showing SKILL.md
SKILL.md
Source instructions · Read-only preview- name
- Airtable Custom Interface Extensions
- description
- Complete SDK reference for building Airtable Interface Extensions — models, hooks, components, field types, write patterns, and common pitfalls.
- whenToUse
- When building, modifying, or debugging Airtable Custom Interface Extensions using the @airtable/blocks/interface SDK.
# Airtable Custom Interface Extensions — SDK Reference
You are building Airtable Interface Extensions. These are React components that run inside Airtable Interfaces. They use a specific SDK (`@airtable/blocks/interface`) — NOT the older Blocks SDK or the REST API.
## Quick Start Scaffold
```tsx
import {initializeBlock} from '@airtable/blocks/interface/ui';
import React from 'react';
function App() {
return <div>Hello world</div>;
}
// Entry point — this is NOT ReactDOM.render
initializeBlock({interface: () => <App />});
```
**CLI setup**: `npm install -g @airtable/blocks-cli` → `block init NONE/blkXXX --template=<template-url> my-extension` → `cd my-extension` → `block run`
**Requires**: Node 22+, PAT token with `block:manage` scope configured via `block set-api-key`.
---
## Import Map
Everything comes from two paths:
```tsx
// Models & types
import {FieldType} from '@airtable/blocks/interface/models';
// Hooks, components, utils — everything UI
import {
useBase, useRecords, useCustomProperties, useGlobalConfig,
useSynced, useSession, useRunInfo, useColorScheme, useWatchable,
CellRenderer, expandRecord, initializeBlock,
colors, colorUtils,
loadCSSFromString, loadCSSFromURLAsync, loadScriptFromURLAsync,
} from '@airtable/blocks/interface/ui';
```
There is NO Airtable-provided UI component library beyond `<CellRenderer>`. For buttons, inputs, selects, dialogs, etc., use a third-party library like **MUI** (Material UI — used in Airtable's own sliding-bar-chart example) or plain HTML/React elements. See the Styling & External Libraries section for options.
### Old Blocks SDK vs New Interface Extensions SDK
Claude's training data contains extensive examples from the **old** `@airtable/blocks` SDK. Do NOT use those patterns. Key differences:
| Old Blocks SDK (`@airtable/blocks/ui`) | New Interface Extensions (`@airtable/blocks/interface/ui`) |
|---|---|
| `<Button>`, `<Input>`, `<Box>`, `<FormField>`, `<Select>`, `<Dialog>`, `<Tooltip>`, `<Icon>`, etc. | **None of these exist.** Only `<CellRenderer>`. Use plain HTML/React. |
| `import {useBase} from '@airtable/blocks/ui'` | `import {useBase} from '@airtable/blocks/interface/ui'` |
| `initializeBlock(() => <App />)` | `initializeBlock({interface: () => <App />})` |
| `useRecords(queryResult)` with views/sorts/fields | `useRecords(table)` — table-level only, no view access |
| `cursor`, `viewport`, `useViewport` | Not available |
---
## Styling & External Libraries
### npm packages work
The blocks CLI uses webpack. Any npm package compatible with webpack works. Install via `npm install` in your extension directory.
### Recommended libraries
| Need | Recommended | Notes |
|------|-------------|-------|
| **Charts** | `recharts` (React), `d3` + `d3-cloud` | Airtable's official word-cloud example uses D3. Recharts is easier for standard charts. |
| **Date handling** | `date-fns` or `dayjs` | Lighter than moment.js. Date fields return ISO 8601 strings. |
| **Component library** | `@mui/material` (MUI) | Airtable's own sliding-bar-chart example uses MUI v7 + Emotion. Full component set (buttons, inputs, dialogs, etc.). |
| **Headless UI** | `@radix-ui/react-*` | Accessible primitives (dialogs, dropdowns, tooltips) without styling opinions. Lighter than MUI. |
| **Icons** | `@phosphor-icons/react` | Append `Icon` suffix when importing: `import {ArrowRightIcon} from '@phosphor-icons/react'`. |
| **Drag & drop** | `@dnd-kit/core` | Accessible drag-and-drop for kanban boards, sortable lists, etc. |
| **Markdown** | `marked` | Parse markdown content from rich text or long text fields. |
| **3D models** | `@google/model-viewer` | Render 3D models inline. |
| **CSS framework** | Plain CSS or **Tailwind CSS** | Tailwind is officially supported — used in Airtable's own map extension. See Tailwind section below. |
**React 19 note:** If a third-party library doesn't list React 19 as a peer dependency, use `npm install --legacy-peer-deps` to install it.
### CSS approach
Import CSS files directly — the webpack bundler handles them:
```tsx
import './style.css';
```
For external CSS:
```tsx
await loadCSSFromURLAsync('https://cdn.example.com/library.css');
```
For dynamic CSS:
```tsx
loadCSSFromString(`
.my-card { border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
@media (prefers-color-scheme: dark) {
.my-card { border-color: #444; background: #2a2a2a; }
}
`);
```
### Tailwind CSS — officially supported
Tailwind works with the blocks CLI. Airtable's own [map extension](https://github.com/Airtable/interface-extensions-map) uses this exact setup. The webpack bundler auto-detects PostCSS when the loaders are installed.
**Setup:**
```bash
npm install -D tailwindcss postcss postcss-loader css-loader style-loader autoprefixer @airtable/blocks-webpack-bundler
```
**tailwind.config.js** (at project root):
```js
module.exports = {
// CRITICAL: must be 'media', not 'class'. Airtable controls dark mode via
// prefers-color-scheme, not a CSS class. Using 'class' means dark: utilities
// won't fire unless you manually add a 'dark' class wrapper — and even then
// it won't work on first render before JS hydrates.
darkMode: 'media',
content: ['./frontend/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
// Map Airtable's design tokens to Tailwind utilities
blue: {
DEFAULT: 'rgb(22, 110, 225)',
dark1: 'rgb(13, 82, 172)',
light1: 'rgb(160, 198, 255)',
light2: 'rgb(209, 226, 255)',
},
// Add more Airtable colors as needed — see the full token set at:
// github.com/nabong04/airtable-geocoded-locations-map/blob/main/tailwind.config.js
},
},
},
};
```
**frontend/style.css:**
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
Then `import './style.css'` in your component. Use classes like `bg-blue-light2`, `text-gray-900`, etc.
**Raw CSS dark mode:** For any styles written in plain CSS (not Tailwind utilities), use `@media (prefers-color-scheme: dark)` to match Airtable's dark mode — not `.dark` parent selectors.
**References:**
- [Airtable/interface-extensions-map](https://github.com/Airtable/interface-extensions-map) — official Airtable example with Tailwind + TypeScript + Mapbox
- [nabong04/airtable-geocoded-locations-map](https://github.com/nabong04/airtable-geocoded-locations-map) — community example with full Airtable design token mapping in `tailwind.config.js`
### Design tokens from Airtable
Use the built-in `colors` and `colorUtils` to match Airtable's palette (works without Tailwind):
```tsx
import {colors, colorUtils} from '@airtable/blocks/interface/ui';
// Airtable's blue: '#2d7ff9'
const airtableBlue = colorUtils.getHexForColor(colors.BLUE);
// Check contrast for text on colored backgrounds
if (colorUtils.shouldUseLightTextOnColor(colors.BLUE_DARK_1)) {
// use white text
}
```
Available color families: BLUE, CYAN, GRAY, GREEN, ORANGE, PINK, PURPLE, RED, TEAL, YELLOW — each with base, BRIGHT, DARK_1, LIGHT_1, LIGHT_2 variants.
---
## Reading Data
### Access the base and tables
```tsx
function App() {
const base = useBase();
const table = base.getTableByName('Tasks'); // throws if not found
const table2 = base.getTableByNameIfExists('X'); // returns null if not found
// Also: base.getTableById(), base.getTableByIdIfExists(), base.getTable(idOrName)
// base.tables — all tables (arbitrary order)
// base.name, base.color, base.activeCollaborators, base.workspaceId
}
```
### Read records
```tsx
function RecordList() {
const base = useBase();
const table = base.getTableByName('Tasks');
const records = useRecords(table); // auto-refreshes on changes
return (
<ul>
{records.map(record => (
<li key={record.id}>
{record.name} {/* primary field as string */}
{record.getCellValue('Status')} {/* raw cell value */}
{record.getCellValueAsString('Due Date')} {/* formatted string */}
</li>
))}
</ul>
);
}
```
### Read from multiple tables
Use custom properties to let builders configure which tables to use:
```tsx
function getCustomProperties(base) {
return [
{
key: 'projectsTable',
label: 'Projects Table',
type: 'table',
defaultValue: base.tables.find(t => t.name.toLowerCase().includes('projects')),
},
{
key: 'tasksTable',
label: 'Tasks Table',
type: 'table',
defaultValue: base.tables.find(t => t.name.toLowerCase().includes('tasks')),
},
];
}
function MyExtension() {
const {customPropertyValueByKey} = useCustomProperties(getCustomProperties);
const projectsTable = customPropertyValueByKey.projectsTable;
const tasksTable = customPropertyValueByKey.tasksTable;
const projectRecords = useRecords(projectsTable);
const taskRecords = useRecords(tasksTable);
// ...
}
```
### Field access
**Always use `getFieldIfExists`** — it returns `null` instead of throwing. The throwing variants (`getField`, `getFieldByName`, `getFieldById`) will crash your extension if a field was deleted or isn't visible.
```tsx
const field = table.getFieldIfExists('Status'); // returns Field | null
if (!field) return <div>Please configure the Status field</div>;
field.type // FieldType enum value, e.g. 'singleSelect'
field.name // string
field.options // field-specific options (see FieldType reference)
field.config // { type, options } — useful for type narrowing
field.isComputed // true for formula, rollup, autoNumber, etc.
field.isPrimaryField
field.description // string | null
```
**Best practice:** Don't hardcode field names. Use custom properties to let builders select fields (see Custom Properties section). Use `record.getCellValueAsString(field)` when you just need to display a value without handling each field type individually.
**Always use the FieldType enum for comparisons** — never compare against string literals:
```tsx
import {FieldType} from '@airtable/blocks/interface/models';
// ✅ CORRECT
if (field.type === FieldType.SINGLE_SELECT) { /* ... */ }
// ❌ WRONG — don't use string literals
if (field.type === 'singleSelect') { /* ... */ }
```
---
## Writing Data
### CRITICAL: Always check permissions first
Interface Designer can disable editing per-field. Users may have read-only access. Always check before writing:
```tsx
// Simple boolean check
if (table.hasPermissionToCreateRecord()) { /* ... */ }
if (table.hasPermissionToUpdateRecord(record, {'Status': {name: 'Done'}})) { /* ... */ }
if (table.hasPermissionToDeleteRecord(record)) { /* ... */ }
// Detailed check with reason string (for showing error messages)
const check = table.checkPermissionsForUpdateRecord(record, fields);
if (!check.hasPermission) {
alert(check.reasonDisplayString); // e.g. "You don't have permission to edit this field"
}
```
Use `undefined` as placeholder for unknown values in permission checks:
```tsx
// "Can user update this record at all?" (unknown fields)
table.hasPermissionToUpdateRecord(record);
// "Can user update this field on some record?" (unknown record)
table.hasPermissionToUpdateRecord(undefined, {'Status': undefined});
```
### Create records
```tsx
// Single record — returns Promise<RecordId>
const newId = await table.createRecordAsync({
'Project Name': 'New project',
'Budget': 100,
});
// By field ID instead of name
await table.createRecordAsync({
[nameField.id]: 'New project',
[budgetField.id]: 100,
});
// Batch — max 50 records per call
const ids = await table.createRecordsAsync([
{fields: {'Name': 'Record 1'}},
{fields: {'Name': 'Record 2'}},
]);
```
### Update records
View on GitHubThis SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub