slide-confirm
v22.0.1
Published
This is an Angular Module containing Components/Services using Material
Readme
Slide Confirm
A Material-styled drag-to-confirm control, modeled on iOS's "slide to power off" gesture. A consumer drags a circular thumb across a full-width track; crossing a configurable distance threshold locks the control into a confirmed state and fires a confirmed event. A companion cancel button sits to the left of the track — clicking it resets the gesture back to idle, fires a canceled event, and disables the thumb/swipe until the consumer calls reset().
There are two variations, chosen with the confirmSubmit input:
- Non-submit type (
confirmSubmitoff, the default) — completing the swipe is the whole interaction. The track immediately collapses into a 44px green circle with a checkmark, matching the cancel button's size. - Submit type (
confirmSubmiton) — completing the swipe firesconfirmedbut leaves the confirmed track as a clickable submit button (submitLabel). Only clicking that button — firingsubmit— collapses the track into the same green checkmark circle.
Either way, the accepted end state looks identical: a small green circle with a checkmark. The two types just differ in whether there's one user action to reach it or two.
Anatomy
┌────┐ ┌──────────────────────────────────────────────────┐
│ ✕ │ │ ( thumb ) Slide to confirm │
└────┘ └──────────────────────────────────────────────────┘
cancel 0.5rem gap track
button- Cancel button — a 44px whitesmoke circle with a single icon (
closeby default), positioned left of the track with a0.5remgap. Clicking it resets the gesture to idle, emitscanceled, and disables the thumb untilreset()is called. - Track — a full-width pill. Shows
instructionLabelbefore confirmation. Once confirmed, it's either the green checkmark circle (non-submit type) or a clickable submit button (submit type, until submitted). - Thumb — a 44px circle (Material theme-colored via
color), inset 2px inside the track, draggable along the horizontal axis only. Removed from the DOM once confirmed.
Usage
import { SlideConfirmModule } from 'slide-confirm';<app-slide-confirm
instructionLabel="Slide to submit"
color="warn"
[threshold]="80"
(confirmed)="onConfirmed()"
(canceled)="onCanceled()"
></app-slide-confirm>Resetting after confirm/submit
reset() is a public method, not an @Input()/CVA-driven value, so call it via a template reference or @ViewChild:
<app-slide-confirm #slide (confirmed)="onConfirmed()" (canceled)="onCanceled()"></app-slide-confirm>
<button (click)="slide.reset()">Reset</button>@ViewChild(SlideConfirmComponent) slide!: SlideConfirmComponent;
this.slide.reset();Note that clicking the built-in cancel button already resets the gesture for you (plus emits canceled) — but it also leaves the thumb disabled afterward, so a reset() call (from a "start over" button, or after handling canceled/submit) is what actually re-enables the swipe again.
Reactive forms
The component implements ControlValueAccessor, so formControlName/[formControl] compose for both disabled state and patching:
<app-slide-confirm formControlName="agree" (confirmed)="onConfirmed()"></app-slide-confirm>Disabling the bound FormControl disables the whole gesture (both the thumb and the cancel button). Patching a truthy value (form.patchValue({ agree: true }), or calling writeValue(true) directly) drives the control straight into its accepted state — see Patching via writeValue below for what that looks like in each mode.
Two-step confirm-then-submit
<app-slide-confirm
[confirmSubmit]="true"
submitLabel="Press to submit"
color="warn"
(confirmed)="onConfirmed()"
(canceled)="onCanceled()"
(submit)="onSubmit()"
></app-slide-confirm>Here, sliding past the threshold still fires confirmed (the gesture itself hasn't changed), but the confirmed track renders submitLabel as a real, clickable button rather than collapsing immediately. Clicking it fires submit exactly once and collapses the track into the green checkmark circle. The cancel button stays available through this "confirmed but not yet submitted" window — clicking it resets all the way back to idle (thumb and instruction text reappear, disabled) rather than just back to the submit button.
Inputs
| Input | Type | Default | Description |
|---|---|---|---|
| threshold | number | 80 | Percentage of the track's live rendered width the thumb must cross (and stay past, at release) to confirm. |
| instructionLabel | string | 'Slide to confirm' | Text shown in the track before confirmation. |
| color | ThemePalette | 'primary' | Material theme palette ('primary' \| 'accent' \| 'warn') applied to the thumb, and to the track's background while confirmed-but-not-yet-submitted (submit type only — the accepted end state is always green). |
| icon | string | 'chevron_right' | Material icon shown inside the drag thumb. |
| cancelIcon | string | 'close' | Material icon shown inside the cancel button. |
| disabled | boolean | false | Disables the thumb (drag + keyboard) and the cancel button together. Also settable via ControlValueAccessor.setDisabledState. |
| confirmSubmit | boolean | false | When true, the confirmed state renders as a clickable submit button (submitLabel) instead of collapsing to the checkmark immediately. |
| submitLabel | string | 'Press to submit' | Text shown on the submit button. Only used when confirmSubmit is true. |
| submittedIcon | string | 'check' | Material icon shown in the collapsed, accepted-state circle. |
Outputs
| Output | Type | Emitted when |
|---|---|---|
| confirmed | EventEmitter<void> | The drag crosses threshold and is released, or Enter/Space is pressed on the focused thumb. Fires exactly once per confirmation. |
| canceled | EventEmitter<void> | The cancel button is clicked. The cancel button itself isn't a one-shot lock (it can be clicked again later), but each successful cancel disables the thumb — see Cancel resets the gesture and disables the thumb below. |
| submit | EventEmitter<void> | The submit button is clicked (only reachable when confirmSubmit is true and the control is already confirmed). Fires exactly once. |
Public methods
| Method | Description |
|---|---|
| reset() | Clears the confirmed, submitted, and canceled-lock state, restoring the thumb to its interactive idle state. Does not change an externally-set disabled input — a consumer that disabled the control independently stays responsible for re-enabling it. |
Implementation notes
State machine
drag < threshold, released
┌──────────────────────────────────┐
│ ▼
┌──────────┐ pointerdown/keydown ┌──────────────┐
┌───▶│ idle │────────────────────────▶│ dragging │
│ └──────────┘ └──────────────┘
│ ▲ │
│ │ cancel clicked (resets, emits │ drag ≥ threshold, released
│ │ canceled — from idle this is a no-op) │ (or Enter/Space)
│ │ ▼
│ ┌──────────────┐
│ confirmSubmit off: collapses │ confirmed │
│ straight to the checkmark ◀────│ │
│ └──────────────┘
│ │ confirmSubmit on:
│ │ shows submit button;
│ │ cancel still resets
│ │ straight to idle from
│ ▼ here too
│ ┌──────────────┐
└───────────────── reset() ────────────────│ submitted │
│ (collapsed) │
└──────────────┘isConfirmed and isSubmitted are separate internal booleans (not a single enum) so each can independently gate its own visual treatment. Both are cleared together by reset().
Cancel is a reset, not a lock
Earlier iterations of this component treated "canceled" as its own locked, terminal state (mirroring "confirmed") that required an external reset() call to clear. That made the cancel button disable the thumb it sits next to, which reads wrong for a cancel action — clicking cancel should hand control back, not take more away. onCancel() now just calls the same internal reset logic reset() uses (clearing isConfirmed/isSubmitted/drag position) and emits canceled, so:
- It can be clicked more than once — there's nothing left over to "unlock."
- Right after a cancel, the thumb is immediately draggable again (
isLockedno longer references anything cancel-related). - Canceling during the submit type's "confirmed but not yet submitted" window resets all the way back to idle (thumb and instruction text reappear), not to some intermediate disabled state.
Cancel is still blocked while disabled is set externally, and while the control is in a state cancel doesn't apply to (already confirmed, for the non-submit type; already submitted, for the submit type) — that part is unchanged, via isCancelLocked.
Three lock getters, not one
isLocked—disabled || isConfirmed. Gates the thumb: whether it can be dragged (cdkDragDisabled) or keyboard-confirmed (Enter/Space), and itstabindex/aria-disabled. The thumb is also removed from the DOM entirely once confirmed, so this mostly matters pre-confirmation.isCancelLocked— gates the cancel button's[disabled]and the guard insideonCancel(). Always locked whiledisabled. Otherwise:confirmSubmitoff → locked onceisConfirmed;confirmSubmiton → stays unlocked through the confirmed-but-not-submitted window and only locks onceisSubmitted.isSubmitLocked—disabled || isSubmitted. Gates the submit button, so a second click can't re-firesubmit.
Drag gesture (Angular CDK, not raw Pointer Events)
The thumb uses @angular/cdk/drag-drop's cdkDrag, axis-locked to horizontal (cdkDragLockAxis="x"), bounded to the track element (cdkDragBoundary). CDK already unifies mouse/touch/pointer input and is already a dependency used elsewhere in this workspace, so this avoids hand-rolled pointer-event math.
Threshold resolved against live width, not a fixed pixel value
The track is always full width (width: 100%; there is deliberately no compact/fullWidth mode — a short track would defeat the point of a deliberate drag-to-confirm gesture). Because its pixel width isn't fixed, calcPercent() reads trackRef.nativeElement.clientWidth and thumbRef.nativeElement.clientWidth live, at the moment they're needed (on every cdkDragMoved tick and at cdkDragEnded), so resizing the container never leaves a stale threshold calculation in place.
Live fade as you drag
onDragMoved computes dragPercent (0–100, clamped) using the same distance/width math as the release check, and the instruction label's opacity binds to 1 - dragPercent / 100 — so the text fades out smoothly as the thumb approaches the threshold. This transition is suppressed while a drag is in progress (.slide-confirm--dragging) so it tracks the pointer 1:1 instead of lagging behind a CSS transition.
Animations only play entering a state, never leaving one
The track's shape (pill → 44px circle) and background-color (gray → theme color → green) both animate going into --confirmed/--submitted, but snap back instantly on reset() — no morphing from a circle back into a pill, no color fading back to gray. This is deliberate: per the CSS transitions spec, a property change is animated only if the new matched state's transition declares that property, and transition doesn't merge across rules with different specificity — only the single highest-specificity match wins. So the base &__track rule declares no transition at all, and every --confirmed/--submitted variant declares the full property list (background-color, max-width, height, border-radius) itself. Going forward into one of those states animates (its own rule supplies the transition); going back to the untransitioned base snaps.
The confirmed-state label/checkmark is also a separate @if-rendered element (not a text swap on the same node), so it's a genuinely new DOM node when it mounts, and a slide-confirm-fade-in keyframe plays automatically. This only ever happens when isConfirmed/isSubmitted flip true — never mid-drag, never on reset.
Snap-back easing
Releasing short of threshold calls event.source.reset() (Angular CDK's built-in method for returning a free-dragged element to its origin) and the thumb's transform transitions back via a plain ease-out — deliberately not a spring/bounce curve, so the thumb decelerates straight into the start position without overshooting past it.
Accessibility
- The thumb has
tabindex="0"when interactive,-1when locked/disabled, andaria-disabledreflects the same state. Enter/Spaceon the focused thumb confirms immediately, as a non-drag fallback (mirroring the reference component's keyboard-focusable handle).prefers-reduced-motion: reducedisables every transition/animation described above — the thumb snap-back, the instruction label's drag-fade, the track's shape/color transitions, and the confirmed-state fade-in — all become instant.
Patching via writeValue
writeValue drives the control programmatically — via formControl.patchValue(...)/setValue(...), or by calling it directly:
- Falsy value (
null,false,undefined) — equivalent to callingreset(): clears the confirmed/submitted state and returns to idle. - Truthy value — patches the control straight into its accepted terminal state, and that state always renders the same checkmark, regardless of
confirmSubmit:- When
confirmSubmitistrue, this is the same state the control would naturally reach after a real drag-confirm followed by a real submit-click:isConfirmedandisSubmittedare both set. - When
confirmSubmitisfalse, there's no submit step of its own —isSubmittedis set anyway, purely so the checkmark renders the same way a submit-type control's completed state does.confirm()itself does the same thing for the non-submit type when a real gesture completes.
- When
Styling
- The track and thumb size (48px track height, 44px thumb/cancel-button diameter, 2px inset) are fixed in the component's SCSS, not configurable via inputs.
- Theme colors come from Material's own CSS custom properties (
--mdc-theme-primary,--mdc-theme-secondary) with a hardcoded fallback forwarn, so the component picks up an app's active Material theme automatically. - The cancel button is always a flat
whitesmokecircle, independent of thecolorinput — it's a secondary/neutral action by design, visually distinct from the theme-colored primary thumb. - The accepted end state (checkmark circle) is always green (
#43a047), independent ofcolor—coloronly affects the thumb, and the submit type's intermediate confirmed-but-not-submitted track color.
