Mobile components

Modals

An alert interrupts. It stops what a person was doing to tell them something they have to act on before anything else can continue.

When to use it

Use an alert when the app cannot proceed without a decision, and the decision has consequences. Deleting something permanently. Discarding unsaved work. Confirming a purchase.

Use an action sheet when you are offering a choice between several things rather than confirming one. Use a notification or an inline message when nothing needs deciding.

Alerts are expensive. They take the screen, break concentration, and train people to dismiss without reading. An app that alerts on every save has an app where nobody reads alerts.

Anatomy

PartValue
Width300px, capped at 100vw - 32px
Radius34px, a plain arc
SurfaceThe shared overlay glass: rgba(247,247,247,0.73) light, rgba(26,26,26,0.9) dark, blur 30px, saturate 180%
Edge0.5px dark hairline outside, a 1px bright line inside the top and bottom, a soft glow along both
Shadow0 8px 48px rgba(0,0,0,0.25) (--p-shadow-overlay)
Padding14px around the actions; the title’s first line sits 22px from the top and left edges
Title17px, weight 590, line height 22px, tracking -0.43px, left-aligned
Message17px, weight 400, line height 22px, tracking -0.43px, 10px below the title
Text to actions24px
Action48px tall, 24px radius, 17px at weight 590
Action gap8px, side by side or stacked
Entry0.24s, scale 1.12 to 1, cubic-bezier(0.22, 1, 0.36, 1)

The actions are capsules on the surface, not divided rows across the bottom. This is the current iOS treatment and it is the biggest visual difference from the alert most people picture.

The text is left-aligned, not centered. It reads as a sentence rather than a caption.

Two actions sit side by side. Three or more stack. The component decides this itself through :has(> :nth-child(3)), so the markup is identical either way. Add .p-alert__actions--stacked to force a column when two labels are too long to sit next to each other. Browsers without :has() keep the row.

Text fields

An alert can ask for a short answer, like a name for a new folder or a password. Put the fields in .p-alert__fields between the body and the actions. Each .p-alert__field is a 52px row; the block is inset 14px, rounded 26px, on the same quiet fill as the actions, with a 1px separator inset 16px between rows.

<div class="p-alert__fields">
  <input class="p-alert__field" type="text" placeholder="Name" aria-label="Name">
  <input class="p-alert__field" type="password" placeholder="Password" aria-label="Password">
</div>

Use one or two fields. Anything longer is a form, and belongs in a sheet. Give every field an accessible name; the placeholder disappears as soon as someone types.

Try it

Escape closes it, Tab is trapped inside it, focus returns to the button that opened it, and a tap outside dismisses it because of data-p-close-on-outside="true". None of that is alert-specific. The alert is a .p-modal, so it inherits the whole manager.

Actions

ClassAppearanceUse it for
.p-alert__actionQuiet fill, label colorCancel and anything neutral
.p-alert__action--primaryFilled accent, white labelThe default action, the one Return would trigger
.p-alert__action--destructiveQuiet fill, red labelDeleting, discarding, anything unrecoverable

Destructive keeps the quiet fill and only turns the label red, #FF383C in light and #FF5257 in dark. iOS keeps the system red here; it does not use the vibrant red the macOS alert composites onto its glass. The dangerous action should never be the most prominent thing on the screen. If someone taps by reflex, reflex should land on Cancel.

Use one primary action per alert, or none. Two filled buttons ask people to compare two equally loud things.

Label the buttons with verbs that say what happens. Delete, Discard, Save draft. Not OK, which tells people nothing about what they just agreed to.

Writing the content

The title is a short question or statement, in sentence case, ending without a period unless it is a question. The message is one or two complete sentences explaining the consequence.

Put the consequence in the message, not in the title. “Delete Q3 Report?” is the question. “This file will be deleted immediately. You cannot undo this action.” is why it matters.

Markup

<div class="p-modal-background">
  <div class="p-modal p-alert" id="confirm-delete"
       data-p-close-on-outside="true"
       role="alertdialog" aria-modal="true" aria-hidden="true"
       aria-labelledby="confirm-delete-title"
       aria-describedby="confirm-delete-message">
    <div class="p-alert__surface">
      <div class="p-alert__body">
        <h2 class="p-alert__title" id="confirm-delete-title">Delete “Q3 Report”?</h2>
        <p class="p-alert__message" id="confirm-delete-message">
          This file will be deleted immediately. You cannot undo this action.
        </p>
      </div>
      <div class="p-alert__actions">
        <button class="p-alert__action" type="button" data-p-cancel>Cancel</button>
        <button class="p-alert__action p-alert__action--destructive" type="button">Delete</button>
      </div>
    </div>
  </div>
</div>

<button class="p-button p-button-touch p-button-primary"
        data-p-open-modal="#confirm-delete">Delete file</button>

One .p-modal-background per page, holding every alert. Open with data-p-open-modal, close with data-p-cancel or data-p-close-modal. Load the manager once:

<script type="module" src="/js/modals.js"></script>

role="alertdialog" rather than role="dialog". It tells a screen reader this is an interruption that needs a response, and it makes the message part of the announcement.

Dark mode

The surface moves to the dark overlay glass, rgba(26,26,26,0.9), with the desktop alert’s dark edge. The quiet action and field fill becomes a faint lift, rgba(255,255,255,0.065), the destructive label lightens to #FF5257, and the title and label colors flip through their tokens.

Accessibility

Focus moves to the first interactive element when the alert opens and returns to the trigger when it closes. Tab cannot leave the alert while it is open.

Reduced motion drops the scale and leaves a 0.25s fade. The alert still appears, it just does not arrive oversized.

Reduced transparency swaps the material for an opaque surface and removes the blur. Text over a translucent panel sitting on arbitrary content cannot be guaranteed to meet 4.5:1, so the fallback is opaque rather than merely darker. Browsers without backdrop-filter get the same treatment.

Increased contrast replaces the edge with a solid 1px keyline and makes the surface opaque. In forced colors the surface and the field block get a system-colored border.

The alert’s 34px corner is a plain arc, as iOS draws it. The field block’s 26px corner is smoothed into a squircle where corner-shape ships.

Sheets

A sheet slides up from the bottom and stops short of the top, so the screen behind it stays partly visible. That gap is the whole point: it says the task is secondary and the thing behind is still there.

Use a sheet when the task has content. Editing details, picking from a long list, reading something. Use an alert when there is one decision. Use an action sheet when there is a short list of choices and nothing to read.

PartValue
Width420px at most, full width below that
Height50vh by default, 92vh with .p-bottom-sheet-large
Radius38px on the top corners only, a plain arc
Background#FFFFFF light, #1C1C1E dark, opaque
Grabber36 x 5px at 2.5px radius, 5px from the top, #CCCCCC light, rgba(235,235,245,0.30) dark
Header12px / 16px padding, title 17px at weight 590, centered
Entry0.4s slide, cubic-bezier(0.32, 0.72, 0, 1)
<div class="p-modal p-bottom-sheet" id="details"
     data-p-close-on-outside="true"
     role="dialog" aria-modal="true" aria-hidden="true"
     aria-labelledby="details-title">
  <div class="p-bottom-sheet__grabber"></div>
  <div class="p-bottom-sheet__header">
    <button class="p-button p-button-touch-small p-button-borderless"
            type="button" data-p-cancel>Cancel</button>
    <h2 class="p-bottom-sheet__title" id="details-title">Details</h2>
    <button class="p-button p-button-touch-small p-button-primary"
            type="button">Done</button>
  </div>
  <div class="p-bottom-sheet__body">
    ...
  </div>
</div>

Detents

.p-bottom-sheet stops at 50vh. Add .p-bottom-sheet-large for 92vh, which leaves the status bar visible, or .p-bottom-sheet-full for everything but a 24px strip. Override --p-bottom-sheet-height for anything else.

Consider the medium detent when the sheet supports what is behind it, like details about a selected row. Consider large when the sheet is the task.

sheets.js makes the grabber real: drag it to move between detents, and flick down past the smallest one to dismiss. List the detents on the sheet as viewport percentages, smallest first.

<div class="p-modal p-bottom-sheet" data-p-detents="50,92">
  <div class="p-bottom-sheet__grabber"></div>
  …
</div>
<script type="module" src="/js/sheets.js"></script>

Without data-p-detents the sheet keeps the height its class gives it and only swipe-to-dismiss applies. Without the script it stays at that height and closes the usual ways. Include the grabber only where one of those is true; a handle that never moves is a promise the interface does not keep.

The header

The header is a three-column grid: leading action, title, trailing action. The title is centered on the sheet rather than on the leftover space, so adding an action to one side does not shift it.

Both actions are optional. A first or last child that is not the title takes the outer column automatically, so a header with only a Done button still puts it on the right.

Use .p-button-touch-small for header actions. They sit in a 46px band, and the regular touch size crowds it.

The body

.p-bottom-sheet__body scrolls, and the header and grabber stay put. It clears the home indicator through env(safe-area-inset-bottom) with a 16px floor.

The sheet is opaque rather than a material. Sheets hold content, often a list of text, and a translucent panel takes its contrast from whatever is scrolling behind it, which cannot be guaranteed to meet 4.5:1.

Reduced motion replaces the slide with a fade. The sheet still arrives, it just does not travel.

Not to be confused with

modals.css ships three things called some kind of sheet, which is Apple’s naming rather than ours.

ClassWhat it is
.p-bottom-sheetThis component. Slides up from the bottom edge.
.p-action-sheetA short list of choices. Lives in actions.css.
.p-sheetThe macOS window sheet, which drops from a title bar.

The legacy modal

The v1 .p-modal is still here and still works, with its 40vw box, scale(1.5) entry, and divided full-width buttons. It is now scoped as .p-modal:not(.p-desktop-modal):not(.p-alert), so adding .p-alert opts a modal out of every v1 rule while keeping the shared manager.

v1v2
.p-modal alone.p-modal.p-alert
.p-modal-button-container.p-alert__actions
.p-modal-button-container > button.p-alert__action

Nothing about the JavaScript changes. Same attributes, same script, same manager.

Copied