Design guidelines
How to make a droplet look like part of Droppy rather than a guest in it.
Droppy's surfaces are dark, dense, and small. A droplet that ignores that reads as a foreign window bolted onto the notch, and that is the most common reason a submission is sent back for design rather than for code.
Everything below is available as a token or a component. Use them: a hardcoded number is a number that stops matching the next time Droppy's styling moves, whereas a token follows it.
Surfaces are dark
Notch and shelf surfaces are dark chrome, always. Foreground colours come from AdaptiveColors:
Text(title).foregroundStyle(AdaptiveColors.notchSurfacePrimaryText)
Text(detail).foregroundStyle(AdaptiveColors.notchSurfaceSecondaryText)
Text(hint).foregroundStyle(AdaptiveColors.notchSurfaceTertiaryText)Do not put your widget in a card. The shelf's black is the background: almost every one of Droppy's own widgets lays its content directly on the shell, with no fill and no border around it, and a droplet should do the same. Droppy paints nothing behind a widget either, so there is no card to match; a fill or an outline around the whole widget is exactly what makes a droplet look like it came from somewhere else. notchSurfaceCardFill is for a surface inside the widget that has to read as raised, a tile or a chip, never a frame around it.
Lay the widget out like Droppy's
Droppy's own widgets share one layout, and a droplet that follows it reads as one of them. The rules, in the order they are usually broken:
- Fill the rectangle. The root view takes the full width and height the shelf hands it:
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading). Content that floats in the middle of an otherwise empty rectangle, or hugs one corner of it, is the surest sign of a widget that was not built for the shelf. - One padding, and the host gives you the number.
.padding(context.contentInsets)at the root, once, and nothing on top of it. The host clips a widget at a rounded card corner, andcontentInsetsis the clearance for it: 8 on every edge alone under a notch, where the corner is 18pt (a square glyph needs 5.25 to clear it). A solo card filling the Dynamic Island takes 0: no card corner clips it, the island shell does, so every widget on it sits flush to its rectangle and the island keeps one even gap around all of them. A widget in a grouped row takes 0 on every edge too, on either shape (1.21.0): the host clips a grouped card square so its rows land on the row's lines beside Droppy's own (next rule). A widget wrapped around the camera (below) takes 0 on every edge: the host clips a wrapped card square, so no corner arc can shave a control, and its row starts at the card's top edge with its controls on the card's sides, where Droppy's own wrapped rows sit. Artwork that fills a wrapped card rounds itself, as on a solo island card. The shelf's chrome already owns the rest: it keeps every card 17.5pt from the shell's walls and floor (16 plus the 1.5pt slot inset), clears the cutout, and opens the 32pt shoulders. A widget that pads again, or adds a corner inset of its own, doubles the gap. Droppy's own widgets draw flush and clear the 18pt corner element by element, so there a droplet sits 8pt in from the edge where a built-in row sits flush; the one number is what keeps every droplet's corner content whole. - Artwork that is the widget is the one exception, and it is not a card. The no-card rule (Surfaces are dark, above) still stands: do not put your widget in a card, and give its root no background, fill, outline or rounded box. The exception is only for artwork that IS the widget's content, a picture filling the whole tile, like the keyboard a keyboard-cleaning widget shows or the condition sky Droppy's own Weather card is painted with. That picture shows the tile's edge, so the content over it pads
mdlon every edge in place ofcontentInsets, and the picture goes after the padding (.padding(DroppySpacing.mdl).frame(maxWidth: .infinity, maxHeight: .infinity).background { artwork }) so it still fills the tile to its rounded edge. On a solo island card no card corner clips it, so round it there yourself atshelfFullBleedCornerRadius, as the Weather card does. A fill, a gradient or a rounded box drawn to hold ordinary content is a card, which that rule forbids, not this exception. - No unused space. A rectangle taller than its content is empty shelf the user looks at. Declare the height the content needs through
ShelfWidgetContentHeightinstead of leaving air, and useSpacer(minLength: 0)only to push a bottom row to the bottom, never to fill a height you did not need. - The declared height includes the insets. Since DroppyKit 1.20.1 (Droppy 16.0.1, Playground 1.0.27)
contentInsetsis 8 on every edge of a card alone under the notch, where it used to be 0, so a.fixed(150)widget has 134 points for its content there (from 1.21.0 a grouped row and a row wrapped around the camera get 0, clipped square). A widget sized exactly to its content before 1.20.1 now clips its last row: declare the content's height plus the insets. Pad withcontext.contentInsetsand never with a number of your own: the inset differs by slot and by host (0 for a solo card on the island; 0 under the notch and 12 alone on the island before Droppy 16.0.1), so any fixed padding is wrong somewhere. - Leading text, trailing numbers. Titles and labels sit at the leading edge; a value beside a label sits at the trailing edge with
.monospacedDigit()so it does not jitter. A row is anHStackthat spans the full width, with aSpacer(minLength: DroppySpacing.md)between the two, never two texts with a fixed gap. - The header is a row. A 12pt medium symbol and a 12pt semibold title in
notchSurfaceSecondaryText,xsmapart, thensmdown to the content. A control that acts on the whole widget sits at the trailing end of that row. On a Mac with a notch that row is the one that wraps around the camera. - Rhythm on the 4pt grid.
DroppySpacingsteps between rows and nothing else; no 5, no 13. - Solo and paired are compositions. The paired rectangle has room for one number, not for a shorter list. Branch on
context.isCompact, see Shelf widgets. - A row shares its lines. Two to four widgets in a row share one top line, one bottom line and one gap to the divider (1.21.0). Your first row starts on the rectangle's top edge, your last row ends on its bottom edge (17.5pt over the shell's floor), and nothing pads either. A row is as tall as its tallest widget, so yours may get more height than it declared: the header stays on top, the last row (a control row, a legend, the figures) goes to the bottom, and one
Spacer(minLength: 0)between them takes the rest. Never centre the composition in the leftover, and never leave the leftover under your last row: either way your rows stop lining up with the widget beside you. A control row at either end is 28pt, Droppy's paired wash, so its centre lines up with the control row beside it. An empty state (a message and its one action) puts the action on the bottom line too.
The World Clock example is that layout, wrapped around the camera where the host wraps it (next section) and classic everywhere else:
var body: some View {
VStack(alignment: .leading, spacing: DroppySpacing.sm) {
if let wrap = context.notchWrap {
// Beside the camera: the title in the left band, the control in
// the right one, nothing in between.
HStack(spacing: 0) {
title.frame(maxWidth: wrap.bandWidth, alignment: .leading)
Spacer(minLength: 0)
secondsButton(diameter: 28)
.frame(maxWidth: wrap.bandWidth, alignment: .trailing)
}
.frame(height: wrap.headerHeight)
} else {
HStack(spacing: DroppySpacing.xsm) {
title
Spacer(minLength: 0)
if !context.isCompact { secondsButton(diameter: 34) }
}
}
ForEach(clocks) { clock in
HStack {
Text(clock.label)
.foregroundStyle(AdaptiveColors.notchSurfaceSecondaryText)
Spacer(minLength: DroppySpacing.md)
Text(time(clock))
.monospacedDigit()
.foregroundStyle(AdaptiveColors.notchSurfacePrimaryText)
}
}
Spacer(minLength: 0)
}
.padding(context.contentInsets)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var title: some View {
HStack(spacing: DroppySpacing.xsm) {
Image(systemName: "globe").font(.system(size: 12, weight: .medium))
Text("World Clock").font(.system(size: 12, weight: .semibold))
}
.foregroundStyle(AdaptiveColors.notchSurfaceSecondaryText)
}
private func secondsButton(diameter: CGFloat) -> some View {
Button { showsSeconds.toggle() } label: { Image(systemName: "clock") }
.buttonStyle(DroppyWashCircleButtonStyle(
diameter: diameter,
usesAdaptiveForegrounds: context.usesAdaptiveForegrounds
))
}Wrap the row around the camera where it fits
On a MacBook with a notch, Droppy's own widgets do not leave the two bands beside the camera empty. Notes, Pomodoro, Stay Awake, the Calendar and System Stats lift their top row into them, one half left of the notch and one half right of it, and start everything else under the camera, so the open shelf is shorter by about the row's height. A droplet's widget does the same wherever its row fits (DroppyKit 1.21.0):
- Opt in with the traits. Declare the classic header row's height, inside
contentInsets, and the narrowest band its halves still fit in:ShelfWidgetLayoutTraits(..., notchWrap: ShelfWidgetNotchWrapTraits(headerHeight: 34, minimumBandWidth: 64)). - Read the context, never the Mac.
notchWrapis set only when the host really wrapped the mount: the widget is alone on the shelf of a Mac with a notch, at the Regular size, and the bands are at least as wide as you asked. Everywhere else (a grouped row, the Dynamic Island, a display without a notch, a Droppy older than 1.21.0) it isnil; draw the classic layout then. - Split the row, do not move it. Put the leading half in
frame(maxWidth: wrap.bandWidth, alignment: .leading), the trailing half in the same frame aligned trailing, give the rowwrap.headerHeight, and keep both halves vertically centred in it.contentInsetsis 0 on every edge while you are wrapped, so after the one root padding the row already starts at the card's top edge, beside the camera, and its halves sit on the card's sides. Never add an offset, a top padding or a negative inset of your own. - Nothing in the middle. The space between the bands is under the camera housing. A title that does not fit its band drops its words and keeps its glyph (
ViewThatFits), it never runs under the notch. - Everything else starts under the row. The host makes
headerHeighttall enough that what follows it clears the camera, and grows your rectangle by whatever the wrapped row adds over the height you declared, so your fixed height stays the classic one.
The harness draws the wrapped stage beside the classic one, with the housing outlined, so a half that strays under the camera shows. See Shelf widgets for the full API.
Controls inside a widget are the flat wash
Every control a widget draws on the shelf is the flat wash of Droppy's own Pomodoro and Stay Awake (DroppyKit 1.21.0): a white wash, a semibold glyph and a 0.94 press, no glass, no hover, no outline.
DroppyWashCircleButtonStylefor a symbol andDroppyWashPillButtonStylefor a label.neutralfor everything but the one action the widget is for, which takes.accent(color): Start Timer, Record, Send.- 34pt alone on the shelf, 28pt beside another widget, 28pt in the row wrapped beside the camera (Stay Awake's wrapped control), 24pt for a small choice or a control inside a list row.
- Pass
usesAdaptiveForegroundsasusesAdaptiveForegrounds, so the wash follows the host's transparent notch. - One family per widget. Never wash and glass in one widget, and never a bordered chip, a white button or a wash of your own.
Button("Start") { start() }
.buttonStyle(DroppyWashPillButtonStyle(
height: context.isCompact ? 28 : 34,
tone: .accent(.orange),
usesAdaptiveForegrounds: context.usesAdaptiveForegrounds
))
Button { reset() } label: { Image(systemName: "arrow.counterclockwise") }
.buttonStyle(DroppyWashCircleButtonStyle(
diameter: context.isCompact ? 28 : 34,
usesAdaptiveForegrounds: context.usesAdaptiveForegrounds
))Three things are not this rule's buttons:
- Content that is the widget. A grid of chips that are the things the widget controls (a room's lights, the keys of a keyboard) is the widget's content and keeps the look of its content.
- The live activity row. It is Dynamic Island chrome, and its 24pt controls keep
DroppyLiveActivityControlStyle. - Settings panes. They use unmodified macOS controls:
Buttonwith.borderedfor ordinary actions,.borderedProminentfor a primary action,.borderlessfor an inline one,Togglewith.switch, andPickerorMenufor choices. Let macOS supply their sizing, backgrounds, focus rings and interaction feedback, userole: .destructivefor destructive actions, and do not wrap them in glass, capsule fills, custom borders or hover scaling.
The Liquid Glass styles (DroppyCircleButtonStyle, DroppyQuietButtonStyle, DroppyAccentButtonStyle and DroppyGlassButtonStyle) stay in the SDK and keep working for every droplet that already uses them. Use them for a control floating over content outside a widget, like the shelf's floating close button, not for a new widget's controls.
Spacing and radius
DroppySpacing is a 4pt grid; DroppyRadius is a tiered scale. Pair every radius with style: .continuous — a circular corner beside Droppy's continuous ones is visible.
RoundedRectangle(cornerRadius: DroppyRadius.medium, style: .continuous)
.padding(DroppySpacing.smd)No outlines, no gradients, sentence case
Droppy's design language is flat solid surfaces separated by fill contrast:
- No borders or outlines on cards or buttons. Separate surfaces by fill.
- No gradients, with one exception the SDK already owns — the Settings sidebar's icon tiles.
- Sentence case everywhere. Never ALL-CAPS labels, including small ones.
- No decorative dots inside chips or tags. Plain tinted text pills.
Live activity chrome, not cards
A live activity is mini-HUD chrome. DroppyLiveActivityMetrics carries the real numbers: a 37pt row, 13pt glyphs, 12pt labels, 6pt content spacing, and 24pt circular controls. Use them rather than measuring from a screenshot.
Settings panes use the settings components
A droplet's settings pane is rendered inside Droppy's own Settings window, in the grouped form every native page is built from. Root it in DropletSettingsPane and build it from DropletSettingsCard and its rows, and every row is the Form's own row, so it matches every native page, including after Droppy's styling changes, because those components are kept in step with the app.
DropletSettingsPane {
DropletSettingsCard {
DropletToggleRow(title: "Show seconds", subtitle: "Ticks every second.", isOn: $showsSeconds)
DropletControlRow(title: "Clocks") { DropletValuePill(text: "\(count)") }
}
}Solo and paired are different layouts
A shelf widget alone gets the wide standalone canvas; sharing the row it gets a slice. These are different compositions, not one view at two widths. Switch on context.isCompact, never on a width comparison — see Shelf widgets.
Both notch shapes
context.surface is .builtInNotch on a MacBook and .dynamicIsland on a display without one, where Droppy draws a floating island instead. Content that assumes wings either side of a cut-out has nowhere to go in an island. The harness switches between them so you can check.
The physical notch is cleared for you, except on a strip
On a MacBook the camera housing sits inside every one of these surfaces, and the host keeps your content out of it. You do not pad for it, and padding for it anyway doubles the gap:
- Shelf widget and takeover. The shelf's row starts the cutout's height plus 10 below the screen edge and 48 in from each opened shoulder, then the 1.5pt slot inset; your rectangle begins there. The widget context's
contentInsetsinside that rectangle clears the rectangle's own rounded corner, not the notch: 8 against the 18pt card corner, and 0 for a solo island card, which the island shell clips. A solo widget that opted in to the wrap is the one that rises beside the camera instead: the host moves its rectangle up to 17.5pt below the screen edge, hands it the bands incontext.notchWrap, and still keeps everything under the row clear of the housing. - HUD card and expanded live activity. The card's content is inset by the cutout's height at the top and 48 at the sides, 16 all round on an island (
expandedHUDContentInsets(usesNotchStyle:notchHeight:)). - Compact live activity. You hand the host two accessories and it places them in the two wings beside the housing; nothing you draw can end up under it.
The one exception is a HUD strip. The host gives a strip the whole width of the closed notch plus its wings, with the compact family's 22pt symmetric padding on a notch and 12 on an island, and cannot split one view into two wings: on a MacBook the middle of that width IS the camera housing. Lay the strip out with its content at the two outer edges and nothing in the middle, the way Droppy's own caps-lock and Wi-Fi strips are, and never centre it. notchGeometry (host.environment.notchGeometry) carries closedWidth, the housing's width, and isHardwareNotch; the harness draws the housing's outline over the strip so a centred strip shows its mistake. The World Clock example's strip is the pattern: the glyph at the far left, the time at the far right.
Motion
Use DroppyAnimation's presets. Do not invent springs: Droppy's open and close timings are the app's, and a droplet animating on its own curve inside a host spring reads as lag.
Every motion around a view you hand the host is Droppy's own, run by the same code as Droppy's own surfaces: a live activity row and a HUD appear, grow and close exactly like Droppy's. Never put an entrance transition on those views, or it plays twice. For content you swap while a surface is up, use DroppyTransition: the same transitions, the same blur and the same springs (see Live activities).
Icons follow the user's icon style
macOS 26 draws every app icon Default, Clear or Tinted, light or dark, in the tint the user picked (System Settings, Appearance, Icon and widget style), and Droppy draws droplet icons the same way. Your droplet's own icon needs no change: a Droppy that embeds DroppyKit 1.21.0 draws it in the user's style, with the real Liquid Glass, from the compiled icon droppykit build has put in every bundle since 1.6.2. What you control is how it looks in the five styles that are not Default, through the dark and tinted specializations in Icon Composer; see The icon.
A view of your own that draws artwork in the same style reads it the way the host's icons do, a pinned style first and the user's otherwise:
@Environment(\.droppyIconStyle) private var pinned
var body: some View {
let style = pinned ?? DroppyIconStyleObserver.shared.style
// style.variant: .default, .clear or .tinted
// style.appearance: .light or .dark; style.tint: the tint colour
...
}Reading DroppyIconStyleObserver.shared.style from a body redraws the view when the user changes the style. It needs minAPI 1.21.0; on macOS 14 and 15 it is always Default, in the interface's light or dark. The harness's Overview draws your built icon in all six styles.
Accessibility
Every live activity carries an accessibility title and every HUD an accessibility label — they are required parameters, not options, because a surface this small is one a screen reader user cannot fall back on reading.