Skip to content
ArticlesPower Apps

Power Apps

Getting Started with Code Apps: Build an Interactive Dashboard

A step-by-step tutorial to build and deploy an interactive KPI dashboard as a Power Apps Code App with React, TypeScript, and Recharts.

This is a hands-on tutorial. By the end, you will have a fully interactive executive dashboard running inside Power Platform, complete with animated counters, charts, filters, and dark mode. No prior Code Apps experience required, just React/TypeScript fundamentals and a Power Platform environment.

What you will build

An executive KPI dashboard with:

  • 4 animated KPI cards that count up on load and re-animate when filters change
  • Period filters (7 days / 30 days / 90 days / 1 year)
  • Region filters (All / Europe / Americas / Asia-Pacific)
  • Revenue trend chart (area chart) and Pipeline by stage (bar chart)
  • Top deals table with stage badges and probability bars
  • Dark/Light mode toggle with smooth transitions
  • Toast notifications on every filter change

All client-side with embedded sample data: zero connector setup required.

Dashboard architecture showing the component tree: App with KPI cards, charts, filters, and theme toggle

Try it live

This is the actual app running below: click the filters, toggle dark mode, expand the KPI cards:

The interactive demo is not run inside this article. The original explanation and code are retained below.

Open the standalone dashboard demo — fictional data only; no account or external connection.

Prerequisites

Before starting, make sure you have:

  • Node.js LTS installed (nodejs.org)
  • Visual Studio Code or any editor
  • Power Platform CLI (pac): install instructions
  • A Power Platform environment with Code Apps enabled
  • An authenticated PAC CLI session (pac auth create)
Code App workflow: Scaffold with Vite, Initialize with PAC CLI, Build with React and TypeScript, Deploy with pac code push

Step 1: Scaffold the project

Create a Vite + React + TypeScript project and install Recharts for the charts:

Code
npm create vite@latest code-app-dashboard -- --template react-ts
cd code-app-dashboard
npm install
npm install recharts

Step 2: Initialize the Code App

Register the project as a Power Apps Code App:

Code
pac code init \
  --displayName "Executive Dashboard" \
  --description "Interactive KPI Dashboard built with React"

This creates power.config.json, the metadata file that links your project to Power Platform.

Replace the content of vite.config.ts with:

Code
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  base: './',
})

Step 3: Set up the theme system

Create src/theme.ts. This file defines light and dark token sets as plain JavaScript objects, then applies them to CSS custom properties on the document root. When the theme toggles, every component that uses var(--accent), var(--bg-card), etc. transitions automatically: no CSS-in-JS library needed.

Code
// src/theme.ts
export const lightTheme: Record<string, string> = {
  '--bg-primary': '#f8f9fc',
  '--bg-card': '#ffffff',
  '--bg-card-hover': '#f3f0ff',
  '--text-primary': '#1a1a2e',
  '--text-secondary': '#6b7280',
  '--text-muted': '#9ca3af',
  '--border': '#e5e7eb',
  '--accent': '#7c3aed',
  '--accent-light': '#ede9fe',
  '--accent-gradient': 'linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%)',
  '--success': '#10b981',
  '--success-bg': '#ecfdf5',
  '--warning': '#f59e0b',
  '--warning-bg': '#fffbeb',
  '--danger': '#ef4444',
  '--danger-bg': '#fef2f2',
  '--shadow-sm': '0 1px 3px rgba(0,0,0,0.08)',
  '--shadow-md': '0 4px 12px rgba(0,0,0,0.08)',
  '--shadow-lg': '0 8px 30px rgba(0,0,0,0.1)',
};

export const darkTheme: Record<string, string> = {
  '--bg-primary': '#0f0f1a',
  '--bg-card': '#1a1a2e',
  '--bg-card-hover': '#252540',
  '--text-primary': '#e5e7eb',
  '--text-secondary': '#9ca3af',
  '--text-muted': '#6b7280',
  '--border': '#2d2d44',
  '--accent': '#a78bfa',
  '--accent-light': '#1e1b3a',
  '--accent-gradient': 'linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%)',
  '--success': '#34d399',
  '--success-bg': '#0a2e1f',
  '--warning': '#fbbf24',
  '--warning-bg': '#2e2205',
  '--danger': '#f87171',
  '--danger-bg': '#2e0a0a',
  '--shadow-sm': '0 1px 3px rgba(0,0,0,0.3)',
  '--shadow-md': '0 4px 12px rgba(0,0,0,0.3)',
  '--shadow-lg': '0 8px 30px rgba(0,0,0,0.4)',
};

export function applyTheme(theme: Record<string, string>) {
  const root = document.documentElement;
  Object.entries(theme).forEach(([key, value]) => {
    root.style.setProperty(key, value);
  });
}

Step 4: Create the data module

Create src/data.ts. This is the full file: every value is indexed by [region][period] so that changing any filter immediately maps to the right number. The chart generator functions apply a regional multiplier to the base data so you don't need to duplicate arrays for each region.

In a real app, this is where you would call Dataverse or any connector. For this tutorial, embedded data keeps things dependency-free.

Code
// src/data.ts
export interface KPI {
  id: string;
  label: string;
  icon: string;
  format: 'currency' | 'number' | 'percent';
  trend: 'up' | 'down';
  trendLabel: string;
  values: Record<string, Record<string, number>>;
}

export interface DealRow {
  name: string;
  company: string;
  value: number;
  stage: string;
  region: string;
  probability: number;
}

export interface ChartPoint {
  name: string;
  value: number;
}

export type Period = '7d' | '30d' | '90d' | '1y';
export type Region = 'all' | 'europe' | 'americas' | 'asia';

export const PERIODS: { key: Period; label: string }[] = [
  { key: '7d', label: '7 days' },
  { key: '30d', label: '30 days' },
  { key: '90d', label: '90 days' },
  { key: '1y', label: '1 year' },
];

export const REGIONS: { key: Region; label: string }[] = [
  { key: 'all', label: 'All Regions' },
  { key: 'europe', label: 'Europe' },
  { key: 'americas', label: 'Americas' },
  { key: 'asia', label: 'Asia-Pacific' },
];

export const KPI_DATA: KPI[] = [
  {
    id: 'revenue',
    label: 'Total Revenue',
    icon: '\u{1F4B0}',
    format: 'currency',
    trend: 'up',
    trendLabel: '+12.5%',
    values: {
      all:      { '7d': 284000, '30d': 1240000, '90d': 3820000, '1y': 15400000 },
      europe:   { '7d': 112000, '30d': 496000, '90d': 1528000, '1y': 6160000 },
      americas: { '7d': 125000, '30d': 546000, '90d': 1681000, '1y': 6776000 },
      asia:     { '7d': 47000, '30d': 198000, '90d': 611000, '1y': 2464000 },
    },
  },
  {
    id: 'deals',
    label: 'Active Deals',
    icon: '\u{1F4C8}',
    format: 'number',
    trend: 'up',
    trendLabel: '+8 new',
    values: {
      all:      { '7d': 24, '30d': 67, '90d': 142, '1y': 389 },
      europe:   { '7d': 9, '30d': 26, '90d': 55, '1y': 152 },
      americas: { '7d': 11, '30d': 30, '90d': 63, '1y': 170 },
      asia:     { '7d': 4, '30d': 11, '90d': 24, '1y': 67 },
    },
  },
  {
    id: 'winrate',
    label: 'Win Rate',
    icon: '\u{1F3AF}',
    format: 'percent',
    trend: 'up',
    trendLabel: '+3.2pp',
    values: {
      all:      { '7d': 68, '30d': 64, '90d': 61, '1y': 58 },
      europe:   { '7d': 72, '30d': 67, '90d': 63, '1y': 60 },
      americas: { '7d': 65, '30d': 62, '90d': 59, '1y': 56 },
      asia:     { '7d': 64, '30d': 60, '90d': 58, '1y': 55 },
    },
  },
  {
    id: 'pipeline',
    label: 'Pipeline Value',
    icon: '\u{1F680}',
    format: 'currency',
    trend: 'down',
    trendLabel: '-4.1%',
    values: {
      all:      { '7d': 520000, '30d': 2100000, '90d': 6400000, '1y': 24800000 },
      europe:   { '7d': 208000, '30d': 840000, '90d': 2560000, '1y': 9920000 },
      americas: { '7d': 228000, '30d': 924000, '90d': 2816000, '1y': 10912000 },
      asia:     { '7d': 84000, '30d': 336000, '90d': 1024000, '1y': 3968000 },
    },
  },
];

export function getRevenueChart(period: Period, region: Region): ChartPoint[] {
  const base: Record<string, ChartPoint[]> = {
    '7d': [
      { name: 'Mon', value: 38000 }, { name: 'Tue', value: 42000 }, { name: 'Wed', value: 35000 },
      { name: 'Thu', value: 48000 }, { name: 'Fri', value: 52000 }, { name: 'Sat', value: 31000 },
      { name: 'Sun', value: 38000 },
    ],
    '30d': [
      { name: 'W1', value: 280000 }, { name: 'W2', value: 310000 },
      { name: 'W3', value: 295000 }, { name: 'W4', value: 355000 },
    ],
    '90d': [
      { name: 'Jan', value: 1100000 }, { name: 'Feb', value: 1280000 }, { name: 'Mar', value: 1440000 },
    ],
    '1y': [
      { name: 'Q1', value: 3200000 }, { name: 'Q2', value: 3600000 },
      { name: 'Q3', value: 4100000 }, { name: 'Q4', value: 4500000 },
    ],
  };
  const multiplier =
    region === 'all' ? 1 :
    region === 'americas' ? 0.44 :
    region === 'europe' ? 0.40 : 0.16;
  return (base[period] || base['30d']).map(p => ({ ...p, value: Math.round(p.value * multiplier) }));
}

export function getPipelineByStage(region: Region): ChartPoint[] {
  const multiplier =
    region === 'all' ? 1 :
    region === 'americas' ? 0.44 :
    region === 'europe' ? 0.40 : 0.16;
  return [
    { name: 'Discovery', value: Math.round(840000 * multiplier) },
    { name: 'Proposal', value: Math.round(620000 * multiplier) },
    { name: 'Negotiation', value: Math.round(380000 * multiplier) },
    { name: 'Closing', value: Math.round(260000 * multiplier) },
  ];
}

export function getTopDeals(region: Region): DealRow[] {
  const deals: DealRow[] = [
    { name: 'Cloud Migration', company: 'Contoso Ltd', value: 450000, stage: 'Negotiation', region: 'europe', probability: 75 },
    { name: 'ERP Upgrade', company: 'Fabrikam Inc', value: 380000, stage: 'Proposal', region: 'americas', probability: 60 },
    { name: 'Data Platform', company: 'Northwind Traders', value: 320000, stage: 'Closing', region: 'europe', probability: 90 },
    { name: 'Security Audit', company: 'Adventure Works', value: 180000, stage: 'Discovery', region: 'americas', probability: 40 },
    { name: 'AI Integration', company: 'Tailspin Toys', value: 520000, stage: 'Proposal', region: 'asia', probability: 55 },
    { name: 'Digital Twin', company: 'Woodgrove Bank', value: 680000, stage: 'Negotiation', region: 'americas', probability: 70 },
    { name: 'IoT Platform', company: 'Litware Inc', value: 290000, stage: 'Closing', region: 'europe', probability: 85 },
    { name: 'Analytics Suite', company: 'Proseware', value: 410000, stage: 'Discovery', region: 'asia', probability: 35 },
  ];
  if (region === 'all') return deals;
  return deals.filter(d => d.region === region);
}

Step 5: Build the dashboard

Create src/App.tsx. The file is shown in full below so you can copy it directly. Key patterns are explained after the code block.

Code
// src/App.tsx
import { useState, useEffect, useCallback, useRef } from 'react';
import {
  BarChart, Bar, AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
} from 'recharts';
import {
  KPI_DATA, PERIODS, REGIONS, getRevenueChart, getPipelineByStage, getTopDeals,
  type Period, type Region, type KPI,
} from './data';
import { lightTheme, darkTheme, applyTheme } from './theme';
import './styles.css';

/* ── Animated Counter Hook ── */
function useAnimatedValue(target: number, duration = 800) {
  const [value, setValue] = useState(0);
  const rafRef = useRef<number>(0);

  useEffect(() => {
    const start = value;
    const diff = target - start;
    const startTime = performance.now();

    function tick(now: number) {
      const elapsed = now - startTime;
      const progress = Math.min(elapsed / duration, 1);
      const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
      setValue(Math.round(start + diff * eased));
      if (progress < 1) rafRef.current = requestAnimationFrame(tick);
    }

    rafRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafRef.current);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [target, duration]);

  return value;
}

/* ── Format Helpers ── */
function formatValue(n: number, format: KPI['format']) {
  if (format === 'currency') {
    if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
    if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`;
    return `$${n}`;
  }
  if (format === 'percent') return `${n}%`;
  return n.toLocaleString();
}

function formatCurrency(n: number) {
  if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`;
  if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}K`;
  return `$${n}`;
}

/* ── KPI Card Component ── */
function KpiCard({ kpi, period, region, expanded, onToggle }: {
  kpi: KPI; period: Period; region: Region; expanded: boolean; onToggle: () => void;
}) {
  const raw = kpi.values[region]?.[period] ?? kpi.values['all'][period];
  const animated = useAnimatedValue(raw);
  const allPeriods = (['7d', '30d', '90d', '1y'] as Period[]).filter(p => p !== period);

  return (
    <div className={`kpi-card${expanded ? ' expanded' : ''}`} onClick={onToggle}>
      <div className="kpi-header">
        <span className="kpi-label">{kpi.label}</span>
        <span className="kpi-icon">{kpi.icon}</span>
      </div>
      <div className="kpi-value" key={`${region}-${period}`}>
        {formatValue(animated, kpi.format)}
      </div>
      <span className={`kpi-trend ${kpi.trend}`}>
        {kpi.trend === 'up' ? '\u2191' : '\u2193'} {kpi.trendLabel}
      </span>
      {expanded && (
        <div className="kpi-detail">
          {allPeriods.map(p => (
            <div className="kpi-detail-row" key={p}>
              <span>{PERIODS.find(x => x.key === p)?.label}</span>
              <span>{formatValue(kpi.values[region]?.[p] ?? kpi.values['all'][p], kpi.format)}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

/* ── Custom Tooltip ── */
function ChartTooltip({ active, payload, label }: {
  active?: boolean; payload?: { value: number }[]; label?: string;
}) {
  if (!active || !payload?.length) return null;
  return (
    <div style={{
      background: 'var(--bg-card)', border: '1px solid var(--border)',
      borderRadius: 10, padding: '0.5rem 0.75rem', boxShadow: 'var(--shadow-md)',
    }}>
      <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{label}</div>
      <div style={{ fontSize: '0.95rem', fontWeight: 700, color: 'var(--text-primary)' }}>
        {formatCurrency(payload[0].value)}
      </div>
    </div>
  );
}

/* ── Main App ── */
export default function App() {
  const [dark, setDark] = useState(false);
  const [period, setPeriod] = useState<Period>('30d');
  const [region, setRegion] = useState<Region>('all');
  const [expandedKpi, setExpandedKpi] = useState<string | null>(null);
  const [toast, setToast] = useState<string | null>(null);

  useEffect(() => {
    applyTheme(dark ? darkTheme : lightTheme);
  }, [dark]);

  const showToast = useCallback((msg: string) => {
    setToast(msg);
    setTimeout(() => setToast(null), 2000);
  }, []);

  const handlePeriod = (p: Period) => {
    setPeriod(p);
    showToast(`Showing ${PERIODS.find(x => x.key === p)?.label} data`);
  };

  const handleRegion = (r: Region) => {
    setRegion(r);
    showToast(`Filtered: ${REGIONS.find(x => x.key === r)?.label}`);
  };

  const revenueData = getRevenueChart(period, region);
  const pipelineData = getPipelineByStage(region);
  const deals = getTopDeals(region);
  const barColors = ['#7c3aed', '#a78bfa', '#10b981', '#f59e0b'];

  return (
    <div className="dashboard">
      {/* Header */}
      <div className="header">
        <h1>Executive Dashboard</h1>
        <div className="header-actions">
          <button
            className={`theme-toggle${dark ? ' dark' : ''}`}
            onClick={() => setDark(d => !d)}
            title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
            aria-label="Toggle theme"
          />
        </div>
      </div>

      {/* Period + Region Filters */}
      <div className="filters">
        {PERIODS.map(p => (
          <button
            key={p.key}
            className={`pill${period === p.key ? ' active' : ''}`}
            onClick={() => handlePeriod(p.key)}
          >
            {p.label}
          </button>
        ))}
        <span style={{ width: 1, background: 'var(--border)', margin: '0 0.25rem' }} />
        {REGIONS.map(r => (
          <button
            key={r.key}
            className={`pill${region === r.key ? ' active' : ''}`}
            onClick={() => handleRegion(r.key)}
          >
            {r.label}
          </button>
        ))}
      </div>

      {/* KPI Cards */}
      <div className="kpi-grid">
        {KPI_DATA.map(kpi => (
          <KpiCard
            key={kpi.id}
            kpi={kpi}
            period={period}
            region={region}
            expanded={expandedKpi === kpi.id}
            onToggle={() => setExpandedKpi(prev => prev === kpi.id ? null : kpi.id)}
          />
        ))}
      </div>

      {/* Charts */}
      <div className="charts-grid">
        <div className="chart-card">
          <h3>Revenue Trend</h3>
          <ResponsiveContainer width="100%" height={240}>
            <AreaChart data={revenueData}>
              <defs>
                <linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
                  <stop offset="0%" stopColor="#7c3aed" stopOpacity={0.3} />
                  <stop offset="100%" stopColor="#7c3aed" stopOpacity={0} />
                </linearGradient>
              </defs>
              <XAxis dataKey="name" axisLine={false} tickLine={false}
                tick={{ fill: 'var(--text-muted)', fontSize: 12 }} />
              <YAxis hide />
              <Tooltip content={<ChartTooltip />} />
              <Area type="monotone" dataKey="value" stroke="#7c3aed"
                strokeWidth={2.5} fill="url(#areaGrad)" animationDuration={800} />
            </AreaChart>
          </ResponsiveContainer>
        </div>

        <div className="chart-card">
          <h3>Pipeline by Stage</h3>
          <ResponsiveContainer width="100%" height={240}>
            <BarChart data={pipelineData} barCategoryGap="25%">
              <XAxis dataKey="name" axisLine={false} tickLine={false}
                tick={{ fill: 'var(--text-muted)', fontSize: 12 }} />
              <YAxis hide />
              <Tooltip content={<ChartTooltip />} />
              <Bar dataKey="value" radius={[8, 8, 0, 0]} animationDuration={800}>
                {pipelineData.map((_, i) => (
                  <Cell key={i} fill={barColors[i % barColors.length]} />
                ))}
              </Bar>
            </BarChart>
          </ResponsiveContainer>
        </div>
      </div>

      {/* Deals Table */}
      <div className="deals-card">
        <h3>Top Deals</h3>
        <table className="deals-table">
          <thead>
            <tr>
              <th>Deal</th>
              <th>Value</th>
              <th>Stage</th>
              <th>Probability</th>
            </tr>
          </thead>
          <tbody>
            {deals.map(d => (
              <tr key={d.name}>
                <td>
                  <div style={{ fontWeight: 600 }}>{d.name}</div>
                  <div className="company">{d.company}</div>
                </td>
                <td style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
                  {formatCurrency(d.value)}
                </td>
                <td>
                  <span className={`stage-badge ${d.stage.toLowerCase()}`}>{d.stage}</span>
                </td>
                <td>
                  <span className="prob-bar-bg">
                    <span className="prob-bar" style={{ width: `${d.probability}%` }} />
                  </span>
                  {d.probability}%
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {/* Footer */}
      <div className="footer">
        Built with React + Recharts — Powered by Power Apps Code Apps
      </div>

      {/* Toast */}
      <div className={`toast${toast ? ' visible' : ''}`}>{toast}</div>
    </div>
  );
}

Key patterns in the code above:

  • useAnimatedValue is a custom hook that uses requestAnimationFrame with an easeOutCubic easing function: it counts from the current displayed value to the new target, so filter changes animate smoothly without resetting to zero.
  • KpiCard is stateless: it receives expanded and onToggle from the parent. Clicking a card expands it to show all period values for the current region.
  • ChartTooltip renders using CSS variables directly in the style prop, so it inherits the active theme automatically.
  • The toast system uses a setTimeout to clear the message: simple and effective without needing a library.

Step 6: Add the styles

Create src/styles.css. This is the full file:

Code
/* ── Reset & Base ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  --bg-primary: #f8f9fc;
  --bg-card: #ffffff;
  --bg-card-hover: #f3f0ff;
  --text-primary: #1a1a2e;
  --text-secondary: #6b7280;
  --text-muted: #9ca3af;
  --border: #e5e7eb;
  --accent: #7c3aed;
  --accent-light: #ede9fe;
  --accent-gradient: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
  --success: #10b981;
  --success-bg: #ecfdf5;
  --warning: #f59e0b;
  --warning-bg: #fffbeb;
  --danger: #ef4444;
  --danger-bg: #fef2f2;
  --shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
  --shadow-md: 0 4px 12px rgba(0,0,0,0.08);
  --shadow-lg: 0 8px 30px rgba(0,0,0,0.1);
}

body {
  font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
  background: var(--bg-primary);
  color: var(--text-primary);
  line-height: 1.5;
  transition: background 0.3s ease, color 0.3s ease;
}

/* ── Layout ── */
.dashboard {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem 1.5rem;
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 2rem;
  flex-wrap: wrap;
  gap: 1rem;
}

.header h1 {
  font-size: 1.75rem;
  font-weight: 700;
  background: var(--accent-gradient);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}

.header-actions {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

/* ── Toggle Button ── */
.theme-toggle {
  width: 44px;
  height: 24px;
  border-radius: 12px;
  border: 2px solid var(--border);
  background: var(--bg-card);
  cursor: pointer;
  position: relative;
  transition: all 0.3s ease;
}

.theme-toggle::after {
  content: '';
  position: absolute;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--accent);
  top: 2px;
  left: 2px;
  transition: transform 0.3s ease;
}

.theme-toggle.dark::after {
  transform: translateX(20px);
}

/* ── Filter Pills ── */
.filters {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-bottom: 1.5rem;
}

.pill {
  padding: 0.5rem 1rem;
  border-radius: 9999px;
  border: 1.5px solid var(--border);
  background: var(--bg-card);
  color: var(--text-secondary);
  font-size: 0.85rem;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease;
  user-select: none;
}

.pill:hover {
  border-color: var(--accent);
  color: var(--accent);
}

.pill.active {
  background: var(--accent);
  border-color: var(--accent);
  color: white;
}

/* ── KPI Cards ── */
.kpi-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 1rem;
  margin-bottom: 2rem;
}

.kpi-card {
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 1.5rem;
  box-shadow: var(--shadow-sm);
  cursor: pointer;
  transition: all 0.3s ease;
  position: relative;
  overflow: hidden;
}

.kpi-card::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  background: var(--accent-gradient);
  opacity: 0;
  transition: opacity 0.3s ease;
}

.kpi-card:hover {
  transform: translateY(-2px);
  box-shadow: var(--shadow-md);
  border-color: var(--accent);
}

.kpi-card:hover::before { opacity: 1; }

.kpi-card.expanded {
  grid-column: 1 / -1;
}

.kpi-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 0.75rem;
}

.kpi-icon { font-size: 1.5rem; }

.kpi-label {
  font-size: 0.85rem;
  color: var(--text-secondary);
  font-weight: 500;
}

.kpi-value {
  font-size: 2rem;
  font-weight: 700;
  margin-bottom: 0.25rem;
  font-variant-numeric: tabular-nums;
  animation: fadeInUp 0.5s ease forwards;
}

.kpi-trend {
  font-size: 0.8rem;
  font-weight: 600;
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.15rem 0.5rem;
  border-radius: 6px;
}

.kpi-trend.up  { color: var(--success); background: var(--success-bg); }
.kpi-trend.down { color: var(--danger);  background: var(--danger-bg); }

/* ── Charts Grid ── */
.charts-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.5rem;
  margin-bottom: 2rem;
}

@media (max-width: 768px) {
  .charts-grid { grid-template-columns: 1fr; }
}

.chart-card {
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 1.5rem;
  box-shadow: var(--shadow-sm);
}

.chart-card h3 {
  font-size: 1rem;
  font-weight: 600;
  margin-bottom: 1rem;
  color: var(--text-primary);
}

/* ── Deals Table ── */
.deals-card {
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 1.5rem;
  box-shadow: var(--shadow-sm);
  margin-bottom: 2rem;
}

.deals-card h3 {
  font-size: 1rem;
  font-weight: 600;
  margin-bottom: 1rem;
}

.deals-table {
  width: 100%;
  border-collapse: collapse;
}

.deals-table th {
  text-align: left;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  padding: 0.75rem 1rem;
  border-bottom: 2px solid var(--border);
}

.deals-table td {
  padding: 0.75rem 1rem;
  font-size: 0.9rem;
  border-bottom: 1px solid var(--border);
  transition: background 0.15s ease;
}

.deals-table tr:hover td { background: var(--bg-card-hover); }

.deals-table .company {
  color: var(--text-secondary);
  font-size: 0.8rem;
}

.stage-badge {
  display: inline-block;
  padding: 0.2rem 0.6rem;
  border-radius: 6px;
  font-size: 0.75rem;
  font-weight: 600;
}

.stage-badge.discovery   { background: var(--accent-light); color: var(--accent); }
.stage-badge.proposal    { background: var(--warning-bg);   color: var(--warning); }
.stage-badge.negotiation { background: var(--success-bg);   color: var(--success); }
.stage-badge.closing     { background: var(--danger-bg);    color: var(--danger); }

.prob-bar-bg {
  width: 60px;
  height: 6px;
  background: var(--border);
  border-radius: 3px;
  overflow: hidden;
  display: inline-block;
  vertical-align: middle;
  margin-right: 0.5rem;
}

.prob-bar {
  height: 100%;
  border-radius: 3px;
  background: var(--accent);
  transition: width 0.6s ease;
}

/* ── Toast ── */
.toast {
  position: fixed;
  bottom: 2rem;
  right: 2rem;
  background: var(--accent);
  color: white;
  padding: 0.75rem 1.25rem;
  border-radius: 12px;
  font-size: 0.85rem;
  font-weight: 500;
  box-shadow: var(--shadow-lg);
  opacity: 0;
  transform: translateY(10px);
  transition: all 0.3s ease;
  pointer-events: none;
  z-index: 100;
}

.toast.visible {
  opacity: 1;
  transform: translateY(0);
}

/* ── Animations ── */
@keyframes fadeInUp {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* ── KPI Expanded detail ── */
.kpi-detail {
  margin-top: 1rem;
  padding-top: 1rem;
  border-top: 1px solid var(--border);
  animation: fadeInUp 0.3s ease;
}

.kpi-detail-row {
  display: flex;
  justify-content: space-between;
  font-size: 0.85rem;
  padding: 0.25rem 0;
}

.kpi-detail-row span:first-child { color: var(--text-secondary); }
.kpi-detail-row span:last-child  { font-weight: 600; }

/* ── Footer ── */
.footer {
  text-align: center;
  font-size: 0.75rem;
  color: var(--text-muted);
  padding: 1rem 0;
}

The CSS variables defined in :root are the fallback values (light theme). applyTheme() overrides them at runtime on the <html> element: this takes priority over :root, so the dark theme applies instantly.

Step 7: Deploy

Build and push to Power Platform:

Code
npm run build
pac code push

The CLI outputs a Power Apps URL. Open it: your interactive dashboard is live, running inside Power Platform with full Entra ID SSO.

To include it in a managed solution for ALM:

Code
pac code push --solutionName ExecutiveDashboard

Troubleshooting

pac code push fails with auth error Run pac auth list to check your active profile. If it's empty, run pac auth create and follow the browser login flow.

Assets 404 after deployment (blank page or broken layout) Verify vite.config.ts has base: './'. If you use the standard Vite template, this is not set by default. Without it, Vite generates absolute paths (/assets/...) that break on the Power Platform CDN.

"You don't have permission to access this app" Your environment may lack the required licence. Code Apps require Power Apps Premium or the Developer Plan. Check Admin Center > Environments > Licences.

App feature not found in PAC CLI Ensure PAC CLI is up to date: pac install latest. Code Apps require PAC CLI version 1.30 or higher.

The result

You should see:

  • A polished dashboard with 4 animated KPI cards
  • Interactive filters that re-animate all values on change
  • Two responsive charts (area + bar)
  • A deals table with color-coded stage badges
  • A dark/light toggle in the top-right corner
  • Toast notifications on filter changes
  • Full Entra ID SSO: no login prompt

This is something that would take weeks to approximate in Canvas Apps, with significant UX compromises. With Code Apps, it is a standard React project with the full npm ecosystem available.

Code Apps vs Canvas Apps comparison: features like animated counters, charts, dark mode, npm ecosystem, and build time

What's next

You now have a production-quality Code App. Here is what to explore next:

  • Connect to Dataverse: replace sample data with pac code add-data-source and live connector calls
  • Install Fluent UI v9: npm install @fluentui/react-components for Microsoft's design system
  • Add more npm packages: Zustand for state, TanStack Query for caching, Motion for animations
  • Set up Connection References: for proper ALM across Dev/QA/Prod environments
  • Configure CSP: if your app calls external APIs (mandatory since January 30, 2026)

For the full picture, read:

The Complete Guide to Power Apps Code Apps


Sources