About this animation
A security risk gauge with rolling totals and expandable history, with configurable devices, sessions, palette, and fonts.
Why this one
Security dashboards usually leave the risk number static. This piece connects a concrete action to its consequence: revoking access folds the session away, rolls the session counter, lowers the meter, and fades into a calmer risk label. The Nimbus demo changes from 55% and 2/8 to 38% and 1/7.
How it works
- The host supplies device tiles and session metadata, including dates and city codes formatted for its locale.
- A single shared risk value controls the Skia arc length, accent color, rolling percentage, and threshold label fades. The intro fills it from zero in 580 ms.
- A horizontal pan follows the finger; its velocity projects to the open or closed action position. A tap reveals the same action. Pressure uses a separate shared value.
- Revoking a session updates discrete data once. Critical springs fold the row and settle the risk and counters without overshoot. Filtering never changes the risk calculation.
- Digit columns roll only on their own carry, fade by distance, and rest precisely on whole digits; the slash and percent have separate widths.
- The demo calls the imperative API at 1.5 s (laptop filter), 2.5 s (clear), 3.3 s (reveal), 3.9 s (revoke), and 6.1 s (restore). Direct interaction cancels all pending demo timers.
Install
Requires an Expo SDK 58 project with a Gesture Handler root. Copy this folder without demo/.
npx expo install @shopify/react-native-skia@2.14.0 expo-blur@~58.0.5 expo-image@~58.0.17 expo-linear-gradient@~58.0.3 react-native-gesture-handler@~3.3.0 react-native-reanimated@4.7.1 react-native-worklets@0.13.0Configure Reanimated/worklets as required by your host. Skia on web requires the host's CanvasKit setup; this component does not initialize the app renderer. Icons use the included Lucide paths as local SVG data, avoiding one WebGL context per icon. The back button uses native blur on iOS and a translucent surface fallback elsewhere.
Usage
import { SecurityStatusGauge } from './security-status-gauge';
<SecurityStatusGauge
devices={[
{ id: 'work', name: 'Work laptop', icon: 'laptop', detail: '2 items' },
]}
sessions={[
{ id: 'session-1', deviceId: 'work', name: 'Alex Morgan', initials: 'AM', date: 'Oct 12, 1:00 PM', city: 'PAR' },
]}
riskForSessions={(remaining) => remaining.length ? 55 : 12}
onRevoke={(id) => revokeAccessOnYourServer(id)}
/>const gauge = useRef<SecurityStatusGaugeRef>(null);
<SecurityStatusGauge
ref={gauge}
devices={devices}
sessions={sessions}
totalSessions={8}
riskForSessions={(remaining) => remaining.length === 2 ? 55 : 38}
thresholds={[40, 75]}
strokeRatio={0.12}
palette={{ low: '#B3EA68', medium: '#F2F23A' }}
copy={{ security: 'Access health', revoke: 'End session' }}
fontFamily="YourInterfaceFont"
numberFontFamily="YourLightFont"
onDirectoryPress={() => openDirectory()}
onTeamPress={() => openTeam()}
/>
// From event handlers:
gauge.current?.filter('work');
gauge.current?.filter(null);
gauge.current?.reveal('session-1');
gauge.current?.revoke('session-1');
gauge.current?.reset();Revocations are local and optimistic: the host owns persistence, authorization, and errors. onRevoke is called once per local session ID until reset(). If you supply risk instead of riskForSessions, update that prop yourself after an action. reset() restores the currently supplied data; remount with a new key when replacing an entire session dataset. Device IDs must match those in sessions. The demo's risk formula is illustrative, not a security scoring algorithm.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
devices | SecurityDevice[] | required | IDs, names, kit icon names, caller-formatted count labels |
sessions | SecuritySession[] | required | IDs, device IDs, names, initials, caller-formatted dates and city codes |
riskForSessions | (remaining) => number | — | Risk calculator, receives all remaining sessions |
risk | number | 0 | Host-controlled 0–100 risk, used without a calculator |
totalSessions | number | sessions.length | Counter denominator, decremented by local revocations |
onRevoke | (id) => void | — | Persist a local revocation |
onFilterChange | (deviceId | null) => void | — | Observe filtering |
onInteraction | () => void | — | Stop host autoplay on touch, scroll, or accessibility action |
onBackPress | () => void | reset | Back control action |
onDirectoryPress | () => void | clear filter | Directory control action |
onTeamPress | () => void | first device filter | Team control action |
palette | Partial<SecurityPalette> | slate/light/dark card palette | All background, ink, border, glass, avatar, shadow, and risk colors |
copy | Partial<SecurityCopy> | English structural labels | All titles, actions, risk labels, empty state, hint, percent symbol |
thresholds | readonly [number, number] | [40, 75] | Ordered low/medium and medium/high boundaries |
fontFamily | string | system | Body and control font |
numberFontFamily | string | body font | Figures; demo uses Geist Light |
brand | { name, colors, glyph, ink } | none | Host brand tile and wordmark |
strokeRatio | number | 0.12 | Arc thickness relative to gauge width, bounded to 0.06–0.18 |
entranceDuration | number | 580 | Intro duration; action springs remain critical |
formatAnnouncement | (event, value, risk) => string | action plus risk | Caller-localized announcements |
accessibilityLabel | string | security title | Gauge accessible name |
style | StyleProp<ViewStyle> | — | Root styling and host safe-area padding |
testID | string | security-status-gauge | Stable root/control identifiers |
ref: revoke(id), filter(deviceId | null), reveal(id), reset().
Layout and themes
The root fills its host with flex: 1, measures width with onLayout, and caps content width at 468 pt on tablets. Content stays in vertical flow with scrolling in short containers or landscape; device tiles scroll horizontally. The gauge becomes smaller below 350 pt. This is an embeddable product panel: the host supplies safe-area insets. The demo reserves 64 pt above navigation and places the brand beside the title, below the viewer close control. Full palette overrides allow either light or dark host themes without hard-coded body text colors. There are no remote assets, photos, or flags.
Compatibility
Expo SDK 58; React Native 0.88.0; Reanimated 4.7.1; worklets 0.13.0; Skia 2.14.0. Uses installed Expo Go modules.
Accessibility
Controls are at least 48 pt; session rows are at least 64 pt and grow with content. Buttons expose roles, labels, selection, and stable test IDs. Session gestures have a tap alternative and an accessibility action for revocation. Filtering and revoking announce the action and new risk. Hidden or revoked rows are removed from accessibility and touch routing. Reduced motion uses 200 ms fades, stationary digits, and no entrance translation; actions appear beside the row without a sliding front surface.
Icons: Lucide (ISC). Demo brand: Nimbus from the repository identity kit. No reference brand assets are included.




