# Date Range Field

> A start and an end date, each on the native date input, linked so the end is never before the start.

`<slab-date-range-field>` · Form · built on `<input type=date>`

## Install

```sh
pnpm add slab-elements lit
```

## Usage

### React

`MyDateRangeField.jsx`. React 19 or later, which sets props on custom elements as properties.

```jsx
import 'slab-elements/date-range-field.js';

export function MyDateRangeField() {
  return (
    <slab-date-range-field label="Stay" name="stay" start-label="Check-in" end-label="Check-out" required></slab-date-range-field>
  );
}
```

### Lit

`my-date-range-field.ts`. Slab is built on Lit, so its elements are at home in Lit's templates.

```ts
import 'slab-elements/date-range-field.js';
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('my-date-range-field')
export class MyDateRangeField extends LitElement {
  render() {
    return html`
      <slab-date-range-field label="Stay" name="stay" start-label="Check-in" end-label="Check-out" required></slab-date-range-field>
    `;
  }
}
```

### Vue

`MyDateRangeField.vue`. Let Vue's compiler know slab- tags are custom elements with isCustomElement.

```vue
<script setup>
import 'slab-elements/date-range-field.js';
</script>

<template>
  <slab-date-range-field label="Stay" name="stay" start-label="Check-in" end-label="Check-out" required></slab-date-range-field>
</template>
```

### Svelte

`MyDateRangeField.svelte`. Svelte binds properties and events to custom elements like any other element.

```svelte
<script>
  import 'slab-elements/date-range-field.js';
</script>

<slab-date-range-field label="Stay" name="stay" start-label="Check-in" end-label="Check-out" required></slab-date-range-field>
```

### HTML

`index.html`. Any bundler (or an import map) takes care of the imports.

```html
<script type="module">
  import 'slab-elements/date-range-field.js';
</script>

<slab-date-range-field label="Stay" name="stay" start-label="Check-in" end-label="Check-out" required></slab-date-range-field>
```

## Example

Here's a styled version, `<slab-styled-date-range-field>`. It's not in the package — it's an example of what you can build with the headless element. Copy it and make it yours at the bottom of this page.

```html
<slab-styled-date-range-field label="Pour window" start-label="Earliest" end-label="Latest" min="2026-10-12" start-value="2026-10-16" end-value="2026-10-20" required>
  <span slot="description">The crew needs at least one dry day in the window.</span>
</slab-styled-date-range-field>
```

Swap `slab-styled-` for `slab-` in that markup and you've got the same example as it ships: the headless elements with no CSS at all. That's your starting point.

Each input's bound is the other's value, as well as `min` and `max`, so a browser's picker won't offer an end before the start. An end typed before the start isn't changed to fit: it stays, and the field is `rangeUnderflow` and `rangeOverflow`, as a date outside `min` and `max` is on a single input. The form gets two entries under `name`, the start and then the end, even when one is empty.

The group is named by `label`, and each input by it and then its own label, "Start" and "End" unless you set `start-label` and `end-label`, so a screenreader reads "Stay Check-in". Lay the inputs out with `::part(fields)`. For a calendar instead of the browser's picker, [Calendar](calendar.md) with `range` picks the same thing, as "yyyy-mm-dd/yyyy-mm-dd".

## Keyboard

| Key            | What it does                                                                                       |
| -------------- | -------------------------------------------------------------------------------------------------- |
| Tab, Shift+Tab | Moves from the start to the end, and out of the field. Firefox stops at each part of a date first. |
| Enter          | Submits the form.                                                                                  |
| Anything else  | What it does on a native date input, where the browser edits the date.                             |

## Behavior

A date range field with no visual styles: two native `<input type="date">`s, a start and an end, for picking the first and last days of a range, such as a stay or a reporting period. Each is typed into, picked from, and read the way one is, with the browser's own picker. A calendar of Slab's making is `slab-calendar`'s job, and its `range` picks the same thing.

The values are the inputs': ISO "yyyy-mm-dd" strings, or empty ones, as on a Date Field. What a person sees is formatted for their locale by the browser, but what's submitted and read is always that one format.

The two are linked rather than kept apart: the start's latest date is the end, and the end's earliest is the start, as well as `max` and `min`. So a browser's picker won't offer an end before the start, and an end typed before it is `rangeUnderflow` on the end and `rangeOverflow` on the start, just as a single input reports a date outside `min` and `max`. Neither value is changed to fit the other: what's typed stays, and it's invalid.

The group is named by `label`, and each input by it and its own label, so a screenreader reads "Stay Start" and "Stay End". The host delegates focus, so clicking anywhere on it, or a `<label for>` pointing at it, focuses the start.

Validity is the native inputs'. `required`, `min` and `max` are passed through to both, and whatever either decides is mirrored onto the host, so `valueMissing`, `rangeUnderflow`, `rangeOverflow` and `badInput` all mean what they mean on an `<input>`. Nothing is computed twice.

Style it through its parts and through `:state(disabled)`, `:state(error)`, `:state(readonly)` and `:state(required)`. Each input is `::part(input)` as well as `::part(start)` or `::part(end)`, and each of their labels `::part(input-label)` as well as `::part(start-label)` or `::part(end-label)`, so both can be styled at once. Whether the inputs sit side by side or one above the other is layout, so it's left to the styles around it.

## API

### `<slab-date-range-field>`

#### Attributes

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` |  | Names the group. Each input is named by it and its own label. |
| `disabled` | `boolean` | `false` |  |
| `end-label` | `string` |  | The end input's label, read after `label`. "End" when unset. |
| `end-value` | `string` | `''` | The last day of the range, as "yyyy-mm-dd". Like a native input's `value`, the attribute is the default and the property is the current value. |
| `hide-label` | `boolean` | `false` |  |
| `max` | `string` |  | The latest date allowed for either, as "yyyy-mm-dd". |
| `min` | `string` |  | The earliest date allowed for either, as "yyyy-mm-dd". |
| `name` | `string` | `''` | Submitted twice, with the start value and then the end value. |
| `readonly` | `boolean` | `false` |  |
| `required` | `boolean` | `false` | Requires both dates. |
| `start-label` | `string` |  | The start input's label, read after `label`. "Start" when unset. |
| `start-value` | `string` | `''` | The first day of the range, as "yyyy-mm-dd". Like a native input's `value`, the attribute is the default and the property is the current value. |
| `version` | `string` |  |  |

#### Properties

| Name | Type | Description |
| --- | --- | --- |
| `form` (read-only) | `HTMLFormElement \| null` |  |
| `validity` (read-only) | `ValidityState` |  |

#### Methods

| Signature | Description |
| --- | --- |
| `checkValidity(): boolean` |  |
| `focus(options?: FocusOptions): void` |  |
| `reportValidity(): boolean` |  |
| `resetValidityFeedback(): void` | Clears the validity feedback without changing `validity`. |
| `setCustomValidity(message: string): void` |  |
| `setValidity(flags?: ValidityStateFlags, message?: string): void` |  |

#### Events

| Name | Type | Description |
| --- | --- | --- |
| `input` | `Event` |  |
| `change` | `Event` |  |

#### Slots

| Name | Type | Description |
| --- | --- | --- |
| `description` | `Element \| string` |  |

#### CSS parts

| Name | Description |
| --- | --- |
| `container` | The label, the inputs and the description |
| `description` | Text about the field, and its validity message when it has one |
| `end` |  |
| `end-field` | The end input and its label |
| `end-label` | The end input's label |
| `field` | The start input and its label |
| `fields` | Both inputs and their labels |
| `input` |  |
| `input-label` | The start input's label |
| `label` | The label |
| `start` |  |
| `start-field` | The start input and its label |
| `start-label` | The start input's label |
| `validity-message` |  |

## Copy and own it

`<slab-styled-date-range-field>` isn't in the package. It's an example — copy it into your project and change whatever you want. Keyboard, focus, ARIA, forms, and events all come from the headless element, and the styles only reach in through `::part()` and `:state()`, same as yours would.

### Lit

Under the hood, it's just the headless element, subclassed, with `styles` set. That's it. To use it, copy these files, swap the tag in `@customElement()` for one of your own, and import it. `tokens.styles.ts` holds the colors and fonts every styled example shares, so you only need it once.

#### TypeScript

These files are TypeScript, and `@customElement()` is one of Lit's decorators (TypeScript's experimental flavor). You'll need two options in your tsconfig, per [Lit's docs](https://lit.dev/docs/components/decorators/#decorators-typescript): `experimentalDecorators` to compile them, and `useDefineForClassFields` turned off so class fields don't stomp on the accessors Lit sets up.

```json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false
  }
}
```

Using plain JavaScript? Drop the types and register the class with `customElements.define()` instead of the decorator.

#### date-range-field.ts

```ts
import { customElement } from 'lit/decorators.js';
import DateRangeField from 'slab-elements/date-range-field.js';
import styles from './date-range-field.styles.js';
import textInput from './text-input.styles.js';
import tokens from './tokens.styles.js';

declare global {
  interface HTMLElementTagNameMap {
    'slab-styled-date-range-field': StyledDateRangeField;
  }
}

/**
 * Date Range Field, styled: two of Date Field's inputs side by side, each
 * with a small label of its own, wrapping one above the other when there's
 * no room.
 */
@customElement('slab-styled-date-range-field')
export default class StyledDateRangeField extends DateRangeField {
  static override styles = [tokens, textInput, styles];
}
```

#### date-range-field.styles.ts

```ts
import { css } from 'lit';

export default css`
  [part~='fields'] {
    display: grid;
    gap: 0.75rem;
    grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  }

  [part~='field'] {
    display: grid;
    gap: 0.25rem;
  }

  [part~='input-label'] {
    color: var(--ash);
    font-size: 0.8125rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
  }
`;
```

#### text-input.styles.ts

```ts
import { css } from 'lit';

/*
  The look shared by everything typed into: Text Field, Textarea, Number, Date
  Field, Time Field, Combobox and Tags Input, whose fields are
  "::part(input)", or "::part(textarea)" for Textarea. File Drop starts from it
  too, for its label and description.
*/
export default css`
  :host {
    color: var(--ink);
    display: grid;
    font-family: var(--font-body);
  }

  [part~='label'] {
    font-family: var(--font-display);
    font-size: 1.0625rem;
    font-weight: 600;
    margin-block-end: 0.375rem;
  }

  :host(:state(required)) [part~='label']::after {
    color: var(--ember-deep);
    content: ' *' / '';
  }

  [part~='input'],
  [part~='textarea'] {
    background-color: var(--paper);
    block-size: 3rem;
    border: var(--border);
    border-radius: 0;
    box-sizing: border-box;
    color: inherit;
    font: inherit;

    /* A fixed height, shared with Select, so fields line up in a row. */
    padding: 0 0.75rem;
    transition:
      background-color 100ms,
      border-color 100ms,
      box-shadow 100ms,
      translate 100ms;

    &::placeholder {
      color: var(--ash);
    }

    /*
      The outline is transparent rather than gone, so it still shows in forced
      colors, where box shadows don't.
    */
    &:focus-visible {
      box-shadow: 4px 4px 0 var(--ember);
      outline: 2px solid transparent;
      translate: -2px -2px;
    }

    &:disabled {
      background-color: var(--chalk);
      border-style: dashed;
      color: var(--ash);
      cursor: not-allowed;
    }
  }

  [part~='textarea'] {
    block-size: auto;
    min-block-size: 7rem;
    padding: 0.625rem 0.75rem;
    resize: vertical;
  }

  :host(:state(readonly)) [part~='input'],
  :host(:state(readonly)) [part~='textarea'] {
    background-color: var(--chalk);
  }

  :host(:state(error)) [part~='input'],
  :host(:state(error)) [part~='textarea'] {
    border-color: var(--ember-deep);
  }

  /*
    Spaced by what's in it rather than by a gap, so a description with
    nothing in it takes no room, and a control beside the field lines up
    with its bottom edge.
  */
  [part~='description'] {
    color: var(--ash);
    display: grid;
    font-size: 0.875rem;
  }

  ::slotted([slot='description']) {
    margin-block-start: 0.375rem;
  }

  [part~='validity-message'] {
    color: var(--ember-deep);
    font-weight: 600;
    margin-block-start: 0.375rem;

    &::before {
      content: '■ ' / '';
    }
  }

  @media (prefers-reduced-motion: reduce) {
    [part~='input'],
    [part~='textarea'] {
      transition: none;
    }
  }
`;
```

#### tokens.styles.ts

```ts
import { css } from 'lit';

/*
  The styled components' palette and type, taken from the logo: ink for the
  courses, ember for the base, paper behind them.

  Each is a short alias for a "--slab-styled-*" property, with the default as
  its fallback. Retheming is "--slab-styled-ember" and the rest on ":root",
  which every shadow root inherits. Were the defaults declared under those
  names here instead, each host's own declaration would shadow the page's.

  Each default is "light-dark()", so the components follow the page's
  "color-scheme": "dark" for dark, "light" for light, and "light dark" to
  follow the operating system. Ink and paper trade places in the dark, so
  ink is always the text and paper always what's behind it. Contrast is given
  as light, then dark.
*/
export default css`
  :host {
    /* Secondary text. 6.8:1 and 6.9:1 on paper. */
    --ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
    --border: 2px solid var(--ink);

    /* Behind disabled and read-only controls, and hovered rows. */
    --chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));

    /*
      The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
      enough for borders, fills and large text, but not always for small text,
      which takes "--ember-deep".
    */
    --ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));

    /*
      Orange for small text on paper, and for paper text on it. 5.7:1 and
      7.4:1.
    */
    --ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
    --font-body: var(
      --slab-styled-font-body,
      'IBM Plex Sans',
      system-ui,
      sans-serif
    );
    --font-display: var(
      --slab-styled-font-display,
      'Zilla Slab',
      rockwell,
      'Roboto Slab',
      serif
    );
    --font-mono: var(
      --slab-styled-font-mono,
      'IBM Plex Mono',
      ui-monospace,
      monospace
    );
    --ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
    --paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));

    /* The hard, offset shadow a raised surface casts. */
    --shadow: 4px 4px 0 var(--ink);
  }
`;
```

### React, Vue and Svelte

The same styles, written from outside the element, so there's nothing to subclass. The CSS styles every `<slab-date-range-field>` on the page. Import `tokens.css`, the colors and fonts every styled example shares, once, wherever your global styles go.

In React and Vue, each component registers its headless element and imports its CSS. Use it like the element: its props are the element's attributes and properties.

In Svelte, import `date-range-field.js` once, such as in your root layout, and use `<slab-date-range-field>` as is. A Svelte component around it would take `slot="…"` content as its own slot and drop it.

#### DateRangeField.jsx

```jsx
import 'slab-elements/date-range-field.js';
import './date-range-field.css';

export function DateRangeField(props) {
  return <slab-date-range-field {...props} />;
}
```

#### DateRangeField.vue

```vue
<script setup>
import 'slab-elements/date-range-field.js';
import './date-range-field.css';
</script>

<template>
  <slab-date-range-field><slot /></slab-date-range-field>
</template>
```

#### date-range-field.js

```js
import 'slab-elements/date-range-field.js';
import './date-range-field.css';
```

#### date-range-field.css

```css
slab-date-range-field {
  color: var(--ink);
  display: grid;
  font-family: var(--font-body);
}

slab-date-range-field::part(label) {
  font-family: var(--font-display);
  font-size: 1.0625rem;
  font-weight: 600;
  margin-block-end: 0.375rem;
}

slab-date-range-field:state(required)::part(label)::after {
  color: var(--ember-deep);
  content: ' *' / '';
}

slab-date-range-field::part(input),
slab-date-range-field::part(textarea) {
  background-color: var(--paper);
  block-size: 3rem;
  border: var(--border);
  border-radius: 0;
  box-sizing: border-box;
  color: inherit;
  font: inherit;
  /* A fixed height, shared with Select, so fields line up in a row. */
  padding: 0 0.75rem;
  transition:
    background-color 100ms,
    border-color 100ms,
    box-shadow 100ms,
    translate 100ms;
}

slab-date-range-field::part(input)::placeholder,
slab-date-range-field::part(textarea)::placeholder {
  color: var(--ash);
}

/*
  The outline is transparent rather than gone, so it still shows in forced
  colors, where box shadows don't.
*/
slab-date-range-field::part(input):focus-visible,
slab-date-range-field::part(textarea):focus-visible {
  box-shadow: 4px 4px 0 var(--ember);
  outline: 2px solid transparent;
  translate: -2px -2px;
}

slab-date-range-field::part(input):disabled,
slab-date-range-field::part(textarea):disabled {
  background-color: var(--chalk);
  border-style: dashed;
  color: var(--ash);
  cursor: not-allowed;
}

slab-date-range-field::part(textarea) {
  block-size: auto;
  min-block-size: 7rem;
  padding: 0.625rem 0.75rem;
  resize: vertical;
}

slab-date-range-field:state(readonly)::part(input),
slab-date-range-field:state(readonly)::part(textarea) {
  background-color: var(--chalk);
}

slab-date-range-field:state(error)::part(input),
slab-date-range-field:state(error)::part(textarea) {
  border-color: var(--ember-deep);
}

/*
  Spaced by what's in it rather than by a gap, so a description with
  nothing in it takes no room, and a control beside the field lines up
  with its bottom edge.
*/
slab-date-range-field::part(description) {
  color: var(--ash);
  display: grid;
  font-size: 0.875rem;
}

slab-date-range-field > [slot='description'] {
  margin-block-start: 0.375rem;
}

slab-date-range-field::part(validity-message) {
  color: var(--ember-deep);
  font-weight: 600;
  margin-block-start: 0.375rem;
}

slab-date-range-field::part(validity-message)::before {
  content: '■ ' / '';
}

@media (prefers-reduced-motion: reduce) {
  slab-date-range-field::part(input),
  slab-date-range-field::part(textarea) {
    transition: none;
  }
}

slab-date-range-field::part(fields) {
  display: grid;
  gap: 0.75rem;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
}

slab-date-range-field::part(field) {
  display: grid;
  gap: 0.25rem;
}

slab-date-range-field::part(input-label) {
  color: var(--ash);
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
```

#### tokens.css

```css
:root {
  /* Secondary text. 6.8:1 and 6.9:1 on paper. */
  --ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
  --border: 2px solid var(--ink);

  /* Behind disabled and read-only controls, and hovered rows. */
  --chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));

  /*
    The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
    enough for borders, fills and large text, but not always for small text,
    which takes "--ember-deep".
  */
  --ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));

  /*
    Orange for small text on paper, and for paper text on it. 5.7:1 and
    7.4:1.
  */
  --ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
  --font-body: var(
    --slab-styled-font-body,
    'IBM Plex Sans',
    system-ui,
    sans-serif
  );
  --font-display: var(
    --slab-styled-font-display,
    'Zilla Slab',
    rockwell,
    'Roboto Slab',
    serif
  );
  --font-mono: var(
    --slab-styled-font-mono,
    'IBM Plex Mono',
    ui-monospace,
    monospace
  );
  --ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
  --paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));

  /* The hard, offset shadow a raised surface casts. */
  --shadow: 4px 4px 0 var(--ink);
}
```
